diff --git a/.claude/skills/propagate-pr/SKILL.md b/.claude/skills/propagate-pr/SKILL.md new file mode 100644 index 000000000..0900fb03a --- /dev/null +++ b/.claude/skills/propagate-pr/SKILL.md @@ -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: '' +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///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/ --clone -- `, then re-run `inspect-repos.mjs` for that repo. +- `"no_upstream"`: Add the upstream remote with `git -C remote add upstream https://github.com/mui/.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 /setup-worktree.mjs ''` where `` is the absolute path to the skill directory, and `` is: + + ```json + { + "repoPath": "", + "upstreamRemote": "", + "prNumber": , + "sourceRepo": "", + "worktreeDir": "/>" + } + ``` + + 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//`): + + ``` + cd + git apply --3way + ``` + + 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 propagate/-pr- + ``` + +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/ --draft \ + --title "" \ + --body "Propagated from + +## Adjustments + +- +- +..." \ + --head :propagate/-pr- +``` + +- If the push remote is a fork (owner is not `mui`), use `--head :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: +- [ ] +- [ ] +- [ ] +``` + +If confirmed, post with `gh pr comment --body ""`. + +### 8. Clean up worktrees + +Use `AskUserQuestion` to ask the user if they want to clean up the worktrees. If confirmed, remove them: + +``` +git -C worktree remove +``` + +Do this for every repo that was propagated to, regardless of whether a PR was opened. diff --git a/.claude/skills/propagate-pr/fetch-pr.mjs b/.claude/skills/propagate-pr/fetch-pr.mjs new file mode 100755 index 000000000..aa16210f7 --- /dev/null +++ b/.claude/skills/propagate-pr/fetch-pr.mjs @@ -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 "); + 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")); diff --git a/.claude/skills/propagate-pr/inspect-repos.mjs b/.claude/skills/propagate-pr/inspect-repos.mjs new file mode 100755 index 000000000..ead78a2b7 --- /dev/null +++ b/.claude/skills/propagate-pr/inspect-repos.mjs @@ -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 ..."); + 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/) + 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)); diff --git a/.claude/skills/propagate-pr/setup-worktree.mjs b/.claude/skills/propagate-pr/setup-worktree.mjs new file mode 100755 index 000000000..94f296c87 --- /dev/null +++ b/.claude/skills/propagate-pr/setup-worktree.mjs @@ -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, + }), +); diff --git a/.gitignore b/.gitignore index 4d7d1ef4b..0dc1f6b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ next-env.d.ts # Claude Code worktrees .claude/worktrees + +# propagate-pr skill worktrees +.propagate-pr