Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions .claude/skills/propagate-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
name: propagate-pr
description: >
Propagate a PR's changes to other MUI repositories. Takes a PR URL,
fetches the diff, and applies it across selected repos using local clones and worktrees.
argument-hint: '<pr-url>'
disable-model-invocation: true
effort: high
allowed-tools:
- Bash(node *)
- Bash(git *)
- Bash(mkdir *)
- Bash(rm *)
- Bash(cat *)
- Bash(ls *)
- Bash(pnpm *)
- Bash(gh pr view *)
- Bash(gh pr diff *)
- Agent
- AskUserQuestion
- Read
- Glob
- Grep
---

# Propagate PR

Propagate a pull request's changes across multiple MUI repositories.

## PR context

**Metadata**: !`gh pr view $ARGUMENTS --json title,body,number,url,baseRefName`

**Changed files**: !`gh pr diff $ARGUMENTS --name-only`

**Diff saved to disk**: !`node ${CLAUDE_SKILL_DIR}/fetch-pr.mjs $ARGUMENTS`

## Available repos

!`node ${CLAUDE_SKILL_DIR}/inspect-repos.mjs base-ui base-ui-charts base-ui-mosaic base-ui-plus material-ui mui-x mui-public mui-private`

## Steps

### 1. Review PR details

The PR metadata, changed files list, and repo availability have been injected above. The `fetch-pr.mjs` script saved the filtered diff (excluding `pnpm-lock.yaml`) to `.propagate-pr/<source-repo>/<number>/diff.patch` for use by subagents. Extract the PR title, body, number, URL, and source repo name from the metadata.

### 2. Select repos to propagate to

Exclude the source repo (the one the PR is from). Present the available repos to the user, showing status for each. Use `AskUserQuestion` to let them reply with a comma/space-separated list of repo names.

When the user selects a repo with a non-ok status, fix it before proceeding:

- `"not_found"`: Clone it with `gh repo fork mui/<repo-name> --clone -- <path>`, then re-run `inspect-repos.mjs` for that repo.
- `"no_upstream"`: Add the upstream remote with `git -C <path> remote add upstream https://github.com/mui/<repo-name>.git`, then re-run `inspect-repos.mjs` for that repo.

### 3. Launch one subagent per repo (in parallel)

**CRITICAL**: Launch ALL subagents in a **single message** with multiple Agent tool calls. This is the only way to run them in parallel. Do NOT launch them one at a time.

Launch a **general-purpose Agent** for each selected repo. Each subagent receives:

- The diff file path (from step 1)
- The local repo path
- The upstream repo identifier (e.g., `mui/base-ui`)
- The original PR title, body, and URL
- The upstream remote name
- The push remote name

**Subagent instructions** (include all of this in the agent prompt):

1. **Set up the worktree**: Run `node <skill-dir>/setup-worktree.mjs '<json>'` where `<skill-dir>` is the absolute path to the skill directory, and `<json>` is:

```json
{
"repoPath": "<local-repo-path>",
"upstreamRemote": "<upstream-remote-name>",
"prNumber": <number>,
"sourceRepo": "<source-repo-name>",
"worktreeDir": "<absolute-path-to-.propagate-pr/<target-repo>/<number>>"
}
```

This determines the default branch, fetches upstream, and creates the worktree + branch in one call. It returns JSON with `worktreeDir`, `branchName`, and `defaultBranch`.

2. **Apply the diff** in the worktree (at `.propagate-pr/<target-repo>/<number>`):

```
cd <worktree-path>
git apply --3way <diff-file>
```

If `git apply --3way` produces conflicts, use the full PR context (diff, title, body) to understand the intent and resolve conflicts. Read the conflicting files, understand what the PR was trying to change, and apply the same logical change.

3. **Install and dedupe**:

```
pnpm install --no-frozen-lockfile
pnpm dedupe
```

4. **Run validation** (adapt to the target repo's scripts — check `package.json`):

```
pnpm prettier --write .
pnpm eslint --fix
pnpm typescript
```

If the repo uses different script names (e.g., `pnpm lint`, `pnpm typecheck`, `pnpm tsc`), use those instead. Check `package.json` scripts first.

5. **Commit** with the original PR title as the commit message.

6. **Push** to the push remote:

```
git push <push-remote> propagate/<source-repo>-pr-<number>
```

7. **Report back**: Return the branch name, success/failure status, any issues encountered, the **full filesystem path of the worktree**, and a **short summary of adjustments** made compared to the original PR (1-5 concise bullet points, combining related changes).

8. Do **NOT** open a PR. Do **NOT** clean up the worktree.

### 4. Confirm before opening PRs

Collect results from all subagents. Present a summary:

- Which repos succeeded/failed
- The **full filesystem path of each worktree** (so the user can inspect)
- Any issues encountered

Use `AskUserQuestion` to get **explicit confirmation** before opening any PRs. Never open a PR without confirmation.

### 5. Open draft PRs

For each confirmed repo, create a draft PR:

```
gh pr create --repo mui/<repo-name> --draft \
--title "<original PR title>" \
--body "Propagated from <original PR URL>

## Adjustments

- <adjustment 1>
- <adjustment 2>
..." \
--head <fork-owner>:propagate/<source-repo>-pr-<number>
```

- If the push remote is a fork (owner is not `mui`), use `--head <fork-owner>:propagate/...`
- If it's a direct clone (owner is `mui`), use `--head propagate/...` (no owner prefix)

### 6. Print PR links

Output a summary list with a clickable link for every opened PR.

### 7. Offer to comment on the original PR

Show the user a preview of the comment that would be posted, then ask if they want to post it using `AskUserQuestion`. The comment format:

```
Propagated to:
- [ ] <pr-url-1>
- [ ] <pr-url-2>
- [ ] <pr-url-3>
```

If confirmed, post with `gh pr comment <original-pr-url> --body "<comment>"`.

### 8. Clean up worktrees

Use `AskUserQuestion` to ask the user if they want to clean up the worktrees. If confirmed, remove them:

```
git -C <local-repo-path> worktree remove <worktree-path>
```

Do this for every repo that was propagated to, regardless of whether a PR was opened.
64 changes: 64 additions & 0 deletions .claude/skills/propagate-pr/fetch-pr.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env node

// Fetches PR diff (excluding pnpm-lock.yaml), saves it to disk for subagents,
// and outputs the filtered diff to stdout for context injection.
// Also saves metadata.json alongside the diff.

import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

const prUrl = process.argv[2];

if (!prUrl) {
console.error("Usage: fetch-pr.mjs <pr-url>");
process.exit(1);
}

// Fetch metadata and diff in parallel
const [{ stdout: metadataRaw }, { stdout: diffRaw }] = await Promise.all([
execFileAsync("gh", [
"pr",
"view",
prUrl,
"--json",
"title,body,number,url,baseRefName",
]),
execFileAsync("gh", ["pr", "diff", prUrl], { maxBuffer: 50 * 1024 * 1024 }),
]);

const metadata = JSON.parse(metadataRaw);
const prNumber = metadata.number;

// Extract source repo name from the PR URL
const urlMatch = metadata.url.match(/github\.com\/[^/]+\/([^/]+)\/pull\//);
const sourceRepo = urlMatch ? urlMatch[1] : "unknown";

// Filter out pnpm-lock.yaml from diff
let filtered = "";
let skip = false;
for (const line of diffRaw.split("\n")) {
if (line.startsWith("diff --git")) {
skip = line.includes("pnpm-lock.yaml");
}
if (!skip) {
filtered += line + "\n";
}
}

// Save to disk for subagents
const outputDir = join(".propagate-pr", sourceRepo, String(prNumber));
await mkdir(outputDir, { recursive: true });
await Promise.all([
writeFile(
join(outputDir, "metadata.json"),
JSON.stringify(metadata, null, 2) + "\n",
),
writeFile(join(outputDir, "diff.patch"), filtered),
]);

// Output the diff path for subagents to reference
console.log(resolve(outputDir, "diff.patch"));
84 changes: 84 additions & 0 deletions .claude/skills/propagate-pr/inspect-repos.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node

// Usage: inspect-repos.mjs base-ui material-ui mui-x ...
// For each repo, checks if ../repo exists and is a git repo, inspects remotes,
// and determines upstream/push remote names and fork owner.
// Outputs JSON array of results.

import { execFile } from "node:child_process";
import { access } from "node:fs/promises";
import { join, resolve, dirname } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";

const execFileAsync = promisify(execFile);

const repos = process.argv.slice(2);
if (repos.length === 0) {
console.error("Usage: inspect-repos.mjs <repo1> <repo2> ...");
process.exit(1);
}

// Derive paths: sibling directories relative to the project root
const scriptDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(scriptDir, "../../..");
const parentDir = resolve(projectRoot, "..");

const results = await Promise.all(
repos.map(async (repo) => {
const path = join(parentDir, repo);
// Check if path exists and is a git repo
try {
await access(join(path, ".git"));
} catch {
return { repo, path, status: "not_found" };
}

// Get remotes
let stdout;
try {
({ stdout } = await execFileAsync("git", ["-C", path, "remote", "-v"]));
} catch {
return { repo, path, status: "git_error" };
}

const remotes = [];
for (const line of stdout.split("\n")) {
const match = line.match(
/^(\S+)\s+(https:\/\/github\.com\/([^/]+)\/([^/.\s]+?)(?:\.git)?|git@github\.com:([^/]+)\/([^/.\s]+?)(?:\.git)?)\s+\(fetch\)/,
);
if (match) {
const owner = match[3] || match[5];
const repoName = match[4] || match[6];
remotes.push({ name: match[1], owner, repoName });
}
}

// Find upstream remote (points to mui/<repo>)
const upstreamRemote = remotes.find(
(r) => r.owner === "mui" && r.repoName === repo,
);
if (!upstreamRemote) {
return { repo, path, status: "no_upstream", remotes };
}

// Find push remote — must be "origin"
const originRemote = remotes.find((r) => r.name === "origin");
if (!originRemote) {
return { repo, path, status: "no_origin", remotes };
}
const pushRemote = originRemote;

return {
repo,
path,
status: "ok",
upstreamRemote: upstreamRemote.name,
pushRemote: pushRemote.name,
forkOwner: pushRemote.owner,
isDirect: originRemote.owner === "mui",
};
}),
);

console.log(JSON.stringify(results, null, 2));
58 changes: 58 additions & 0 deletions .claude/skills/propagate-pr/setup-worktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node

// Usage: setup-worktree.mjs '{"repoPath": "...", "upstreamRemote": "...", "prNumber": 123, "sourceRepo": "mui-public", "worktreeDir": "..."}'
// Determines default branch, fetches upstream, creates worktree with branch.
// Outputs JSON with worktree path, branch name, and default branch.

import { execFile } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

const input = JSON.parse(process.argv[2]);
const { repoPath, upstreamRemote, prNumber, sourceRepo, worktreeDir } = input;

// Determine default branch
const { stdout: lsRemoteOut } = await execFileAsync("git", [
"-C",
repoPath,
"ls-remote",
"--symref",
upstreamRemote,
"HEAD",
]);

let defaultBranch = "master";
const symrefMatch = lsRemoteOut.match(/ref: refs\/heads\/(\S+)\s+HEAD/);
if (symrefMatch) {
defaultBranch = symrefMatch[1];
}

// Fetch upstream
await execFileAsync("git", ["-C", repoPath, "fetch", upstreamRemote]);

// Create worktree directory parent
await mkdir(dirname(worktreeDir), { recursive: true });

// Create worktree + branch
const branchName = `propagate/${sourceRepo}-pr-${prNumber}`;
await execFileAsync("git", [
"-C",
repoPath,
"worktree",
"add",
worktreeDir,
"-b",
branchName,
`${upstreamRemote}/${defaultBranch}`,
]);

console.log(
JSON.stringify({
worktreeDir,
branchName,
defaultBranch,
}),
);
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ next-env.d.ts

# Claude Code worktrees
.claude/worktrees

# propagate-pr skill worktrees
.propagate-pr
Loading