Skip to content

Commit cbd3d22

Browse files
authored
feat(ci): mship companion pr check (#5079)
1 parent 4a7c2ef commit cbd3d22

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
name: companion-pr-check
2+
3+
# Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a
4+
# cross-repo "Companion:" PR, surface whether that companion is merged yet, so
5+
# copilot and sim stay in lockstep (a change in one often needs the other).
6+
#
7+
# Declare in a PR description (repeatable; shorthand OR full URL both parse):
8+
# Companion: simstudioai/sim#1234
9+
# Companion: https://github.com/simstudioai/sim/pull/1234
10+
#
11+
# Requires a CROSS_REPO_TOKEN secret (fine-grained PAT with pull-requests:read on
12+
# BOTH repos) to read the other repo's PR state. Without it the check still
13+
# surfaces the declared link but reports "couldn't verify".
14+
15+
on:
16+
pull_request:
17+
types: [opened, edited, reopened, synchronize]
18+
branches: [staging, main]
19+
schedule:
20+
# Refresh open staging/main PRs in case the companion merges AFTER this PR was
21+
# opened. CAVEAT: GitHub runs scheduled workflows ONLY from the DEFAULT branch's
22+
# copy of this file — so this auto-refresh activates once the workflow lands on
23+
# the default branch (via the normal promotion), not before. The pull_request
24+
# triggers below always work; re-editing the PR re-runs the check meanwhile.
25+
- cron: '*/30 * * * *'
26+
workflow_dispatch: {}
27+
28+
permissions:
29+
pull-requests: write
30+
issues: write
31+
contents: read
32+
33+
jobs:
34+
companion:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/github-script@v7
38+
env:
39+
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }}
40+
with:
41+
script: |
42+
const STICKY = '<!-- companion-pr-check -->';
43+
// Two ways to declare a companion (either works; both feed this warning):
44+
// 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL)
45+
// 2) refs in a task list under a "## Companion..." heading — which ALSO
46+
// renders a native live badge + progress bar on the PR (the "both" path):
47+
// ## Companion PRs
48+
// - [ ] owner/repo#N
49+
const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi;
50+
const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g;
51+
const { owner, repo } = context.repo;
52+
const crossToken = process.env.CROSS_REPO_TOKEN;
53+
// Read the OTHER repo's PR via a plain REST fetch with the PAT in the
54+
// header — keeps the PAT strictly READ-ONLY and avoids re-instantiating
55+
// Octokit inside github-script (which can't require('@actions/github')).
56+
// Commenting/labeling uses the default GITHUB_TOKEN via `github`.
57+
async function crossGetPR(c) {
58+
const res = await fetch(`https://api.github.com/repos/${c.owner}/${c.repo}/pulls/${c.number}`, {
59+
headers: {
60+
authorization: `Bearer ${crossToken}`,
61+
accept: 'application/vnd.github+json',
62+
'x-github-api-version': '2022-11-28',
63+
'user-agent': 'companion-pr-check',
64+
},
65+
});
66+
if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; }
67+
return res.json();
68+
}
69+
70+
function parseCompanions(body) {
71+
body = body || '';
72+
const out = [];
73+
const seen = new Set();
74+
const add = (o, r, n) => {
75+
const ref = `${o}/${r}#${n}`;
76+
if (seen.has(ref)) return;
77+
seen.add(ref);
78+
out.push({ owner: o, repo: r, number: Number(n), ref });
79+
};
80+
// (1) "Companion:" trailers anywhere in the body.
81+
let m;
82+
TRAILER.lastIndex = 0;
83+
while ((m = TRAILER.exec(body)) !== null) add(m[1], m[2], m[3]);
84+
// (2) refs in a task list under a "## Companion..." heading, until the next heading.
85+
let inSection = false;
86+
for (const line of body.split(/\r?\n/)) {
87+
if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; }
88+
if (!inSection) continue;
89+
let mm;
90+
REF.lastIndex = 0;
91+
while ((mm = REF.exec(line)) !== null) add(mm[1], mm[2], mm[3]);
92+
}
93+
return out;
94+
}
95+
96+
async function findSticky(prNumber) {
97+
const comments = await github.paginate(github.rest.issues.listComments, {
98+
owner, repo, issue_number: prNumber, per_page: 100,
99+
});
100+
return comments.find((c) => (c.body || '').includes(STICKY));
101+
}
102+
async function upsert(prNumber, body) {
103+
const ex = await findSticky(prNumber);
104+
if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body });
105+
else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
106+
}
107+
async function clear(prNumber) {
108+
const ex = await findSticky(prNumber);
109+
if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id });
110+
// Drop the label too, so a PR edited to remove all companions doesn't
111+
// keep a stale has-companion badge. 404 if not present → ignore.
112+
try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'has-companion' }); } catch {}
113+
}
114+
async function ensureLabel() {
115+
try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); }
116+
catch {
117+
try {
118+
await github.rest.issues.createLabel({
119+
owner, repo, name: 'has-companion', color: '5319e7',
120+
description: 'Has a cross-repo companion PR (see companion-pr-check)',
121+
});
122+
} catch {}
123+
}
124+
}
125+
126+
// staging PRs are a single feature → just this PR's body ("the one").
127+
// main (prod) release PRs bundle MANY feature PRs → aggregate the
128+
// companions declared on each squashed feature PR too, so "does any
129+
// commit in this release have a companion?" is answered.
130+
async function collectCompanions(pr) {
131+
const companions = parseCompanions(pr.body);
132+
const seen = new Set(companions.map((c) => c.ref));
133+
if (pr.base.ref === 'main') {
134+
let commits = [];
135+
try {
136+
commits = await github.paginate(github.rest.pulls.listCommits, {
137+
owner, repo, pull_number: pr.number, per_page: 100,
138+
});
139+
} catch {}
140+
const featurePRs = new Set();
141+
const SQUASH = /\(#(\d+)\)/g; // squash-merge refs like "...(#306)"
142+
for (const c of commits) {
143+
const msg = (c.commit && c.commit.message) || '';
144+
let m;
145+
SQUASH.lastIndex = 0;
146+
while ((m = SQUASH.exec(msg)) !== null) featurePRs.add(Number(m[1]));
147+
}
148+
for (const n of featurePRs) {
149+
if (n === pr.number) continue;
150+
try {
151+
const { data: fpr } = await github.rest.pulls.get({ owner, repo, pull_number: n });
152+
for (const c of parseCompanions(fpr.body)) {
153+
if (!seen.has(c.ref)) { seen.add(c.ref); companions.push(c); }
154+
}
155+
} catch {}
156+
}
157+
}
158+
return companions;
159+
}
160+
161+
async function checkPR(pr) {
162+
const companions = await collectCompanions(pr);
163+
if (companions.length === 0) { await clear(pr.number); return; }
164+
await ensureLabel();
165+
try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {}
166+
167+
const base = pr.base.ref;
168+
const lines = [];
169+
let warn = false;
170+
for (const c of companions) {
171+
if (!crossToken) {
172+
lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`);
173+
warn = true;
174+
continue;
175+
}
176+
try {
177+
const cp = await crossGetPR(c);
178+
const title = (cp.title || '').slice(0, 80);
179+
if (cp.merged) {
180+
const tierOk = cp.base.ref === base;
181+
lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`);
182+
if (!tierOk) warn = true;
183+
} else {
184+
lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`);
185+
warn = true;
186+
}
187+
} catch (e) {
188+
lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`);
189+
warn = true;
190+
}
191+
}
192+
const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check';
193+
const scope = base === 'main' ? ' (aggregated across the feature PRs in this release)' : '';
194+
const note = warn
195+
? `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.`
196+
: `All declared companion PRs are merged into \`${base}\`${scope}.`;
197+
await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`);
198+
}
199+
200+
if (context.eventName === 'pull_request') {
201+
await checkPR(context.payload.pull_request);
202+
} else {
203+
for (const b of ['staging', 'main']) {
204+
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 });
205+
for (const pr of prs) await checkPR(pr);
206+
}
207+
}

0 commit comments

Comments
 (0)