diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml new file mode 100644 index 0000000000..430d7e9431 --- /dev/null +++ b/.github/workflows/companion-pr-check.yml @@ -0,0 +1,207 @@ +name: companion-pr-check + +# Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a +# cross-repo "Companion:" PR, surface whether that companion is merged yet, so +# copilot and sim stay in lockstep (a change in one often needs the other). +# +# Declare in a PR description (repeatable; shorthand OR full URL both parse): +# Companion: simstudioai/sim#1234 +# Companion: https://github.com/simstudioai/sim/pull/1234 +# +# Requires a CROSS_REPO_TOKEN secret (fine-grained PAT with pull-requests:read on +# BOTH repos) to read the other repo's PR state. Without it the check still +# surfaces the declared link but reports "couldn't verify". + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + branches: [staging, main] + schedule: + # Refresh open staging/main PRs in case the companion merges AFTER this PR was + # opened. CAVEAT: GitHub runs scheduled workflows ONLY from the DEFAULT branch's + # copy of this file — so this auto-refresh activates once the workflow lands on + # the default branch (via the normal promotion), not before. The pull_request + # triggers below always work; re-editing the PR re-runs the check meanwhile. + - cron: '*/30 * * * *' + workflow_dispatch: {} + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + companion: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} + with: + script: | + const STICKY = ''; + // Two ways to declare a companion (either works; both feed this warning): + // 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL) + // 2) refs in a task list under a "## Companion..." heading — which ALSO + // renders a native live badge + progress bar on the PR (the "both" path): + // ## Companion PRs + // - [ ] owner/repo#N + const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi; + const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; + const { owner, repo } = context.repo; + const crossToken = process.env.CROSS_REPO_TOKEN; + // Read the OTHER repo's PR via a plain REST fetch with the PAT in the + // header — keeps the PAT strictly READ-ONLY and avoids re-instantiating + // Octokit inside github-script (which can't require('@actions/github')). + // Commenting/labeling uses the default GITHUB_TOKEN via `github`. + async function crossGetPR(c) { + const res = await fetch(`https://api.github.com/repos/${c.owner}/${c.repo}/pulls/${c.number}`, { + headers: { + authorization: `Bearer ${crossToken}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'companion-pr-check', + }, + }); + if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; } + return res.json(); + } + + function parseCompanions(body) { + body = body || ''; + const out = []; + const seen = new Set(); + const add = (o, r, n) => { + const ref = `${o}/${r}#${n}`; + if (seen.has(ref)) return; + seen.add(ref); + out.push({ owner: o, repo: r, number: Number(n), ref }); + }; + // (1) "Companion:" trailers anywhere in the body. + let m; + TRAILER.lastIndex = 0; + while ((m = TRAILER.exec(body)) !== null) add(m[1], m[2], m[3]); + // (2) refs in a task list under a "## Companion..." heading, until the next heading. + let inSection = false; + for (const line of body.split(/\r?\n/)) { + if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; } + if (!inSection) continue; + let mm; + REF.lastIndex = 0; + while ((mm = REF.exec(line)) !== null) add(mm[1], mm[2], mm[3]); + } + return out; + } + + async function findSticky(prNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + return comments.find((c) => (c.body || '').includes(STICKY)); + } + async function upsert(prNumber, body) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + } + async function clear(prNumber) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); + // Drop the label too, so a PR edited to remove all companions doesn't + // keep a stale has-companion badge. 404 if not present → ignore. + try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'has-companion' }); } catch {} + } + async function ensureLabel() { + try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); } + catch { + try { + await github.rest.issues.createLabel({ + owner, repo, name: 'has-companion', color: '5319e7', + description: 'Has a cross-repo companion PR (see companion-pr-check)', + }); + } catch {} + } + } + + // staging PRs are a single feature → just this PR's body ("the one"). + // main (prod) release PRs bundle MANY feature PRs → aggregate the + // companions declared on each squashed feature PR too, so "does any + // commit in this release have a companion?" is answered. + async function collectCompanions(pr) { + const companions = parseCompanions(pr.body); + const seen = new Set(companions.map((c) => c.ref)); + if (pr.base.ref === 'main') { + let commits = []; + try { + commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + } catch {} + const featurePRs = new Set(); + const SQUASH = /\(#(\d+)\)/g; // squash-merge refs like "...(#306)" + for (const c of commits) { + const msg = (c.commit && c.commit.message) || ''; + let m; + SQUASH.lastIndex = 0; + while ((m = SQUASH.exec(msg)) !== null) featurePRs.add(Number(m[1])); + } + for (const n of featurePRs) { + if (n === pr.number) continue; + try { + const { data: fpr } = await github.rest.pulls.get({ owner, repo, pull_number: n }); + for (const c of parseCompanions(fpr.body)) { + if (!seen.has(c.ref)) { seen.add(c.ref); companions.push(c); } + } + } catch {} + } + } + return companions; + } + + async function checkPR(pr) { + const companions = await collectCompanions(pr); + if (companions.length === 0) { await clear(pr.number); return; } + await ensureLabel(); + try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {} + + const base = pr.base.ref; + const lines = []; + let warn = false; + for (const c of companions) { + if (!crossToken) { + lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); + warn = true; + continue; + } + try { + const cp = await crossGetPR(c); + const title = (cp.title || '').slice(0, 80); + if (cp.merged) { + const tierOk = cp.base.ref === base; + lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`); + if (!tierOk) warn = true; + } else { + lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`); + warn = true; + } + } catch (e) { + lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`); + warn = true; + } + } + const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check'; + const scope = base === 'main' ? ' (aggregated across the feature PRs in this release)' : ''; + const note = warn + ? `One or more companion PRs aren't merged into \`${base}\` yet${scope}. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` + : `All declared companion PRs are merged into \`${base}\`${scope}.`; + await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`); + } + + if (context.eventName === 'pull_request') { + await checkPR(context.payload.pull_request); + } else { + for (const b of ['staging', 'main']) { + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 }); + for (const pr of prs) await checkPR(pr); + } + }