mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(ci): mship companion pr check (#5079)
This commit is contained in:
@@ -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 = '<!-- companion-pr-check -->';
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user