Skip to content

Commit 4a4205d

Browse files
committed
ci(companion): warn on unmerged cross-repo companion PRs
Mirror of copilot's repo-agnostic companion-pr-check workflow: a soft, non-blocking warning on PRs to staging/main when a declared cross-repo Companion PR isn't merged in lockstep. Declare via a "Companion:" trailer or a "## Companion PRs" task list. Requires the CROSS_REPO_TOKEN secret.
1 parent d538b76 commit 4a4205d

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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
21+
# was opened (scheduled runs use the workflow from the default branch).
22+
- cron: '*/30 * * * *'
23+
workflow_dispatch: {}
24+
25+
permissions:
26+
pull-requests: write
27+
issues: write
28+
contents: read
29+
30+
jobs:
31+
companion:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/github-script@v7
35+
env:
36+
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }}
37+
with:
38+
script: |
39+
const STICKY = '<!-- companion-pr-check -->';
40+
// Two ways to declare a companion (either works; both feed this warning):
41+
// 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL)
42+
// 2) refs in a task list under a "## Companion..." heading — which ALSO
43+
// renders a native live badge + progress bar on the PR (the "both" path):
44+
// ## Companion PRs
45+
// - [ ] owner/repo#N
46+
const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi;
47+
const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g;
48+
const { owner, repo } = context.repo;
49+
const crossToken = process.env.CROSS_REPO_TOKEN;
50+
const cross = crossToken ? require('@actions/github').getOctokit(crossToken) : null;
51+
52+
function parseCompanions(body) {
53+
body = body || '';
54+
const out = [];
55+
const seen = new Set();
56+
const add = (o, r, n) => {
57+
const ref = `${o}/${r}#${n}`;
58+
if (seen.has(ref)) return;
59+
seen.add(ref);
60+
out.push({ owner: o, repo: r, number: Number(n), ref });
61+
};
62+
// (1) "Companion:" trailers anywhere in the body.
63+
let m;
64+
TRAILER.lastIndex = 0;
65+
while ((m = TRAILER.exec(body)) !== null) add(m[1], m[2], m[3]);
66+
// (2) refs in a task list under a "## Companion..." heading, until the next heading.
67+
let inSection = false;
68+
for (const line of body.split(/\r?\n/)) {
69+
if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; }
70+
if (!inSection) continue;
71+
let mm;
72+
REF.lastIndex = 0;
73+
while ((mm = REF.exec(line)) !== null) add(mm[1], mm[2], mm[3]);
74+
}
75+
return out;
76+
}
77+
78+
async function findSticky(prNumber) {
79+
const comments = await github.paginate(github.rest.issues.listComments, {
80+
owner, repo, issue_number: prNumber, per_page: 100,
81+
});
82+
return comments.find((c) => (c.body || '').includes(STICKY));
83+
}
84+
async function upsert(prNumber, body) {
85+
const ex = await findSticky(prNumber);
86+
if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body });
87+
else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
88+
}
89+
async function clear(prNumber) {
90+
const ex = await findSticky(prNumber);
91+
if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id });
92+
}
93+
async function ensureLabel() {
94+
try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); }
95+
catch {
96+
try {
97+
await github.rest.issues.createLabel({
98+
owner, repo, name: 'has-companion', color: '5319e7',
99+
description: 'Has a cross-repo companion PR (see companion-pr-check)',
100+
});
101+
} catch {}
102+
}
103+
}
104+
105+
async function checkPR(pr) {
106+
const companions = parseCompanions(pr.body);
107+
if (companions.length === 0) { await clear(pr.number); return; }
108+
await ensureLabel();
109+
try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {}
110+
111+
const base = pr.base.ref;
112+
const lines = [];
113+
let warn = false;
114+
for (const c of companions) {
115+
if (!cross) {
116+
lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`);
117+
warn = true;
118+
continue;
119+
}
120+
try {
121+
const { data: cp } = await cross.rest.pulls.get({ owner: c.owner, repo: c.repo, pull_number: c.number });
122+
const title = (cp.title || '').slice(0, 80);
123+
if (cp.merged) {
124+
const tierOk = cp.base.ref === base;
125+
lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`);
126+
if (!tierOk) warn = true;
127+
} else {
128+
lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`);
129+
warn = true;
130+
}
131+
} catch (e) {
132+
lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`);
133+
warn = true;
134+
}
135+
}
136+
const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check';
137+
const note = warn
138+
? `One or more companion PRs aren't merged into \`${base}\` yet. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.`
139+
: `All declared companion PRs are merged into \`${base}\`.`;
140+
await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`);
141+
}
142+
143+
if (context.eventName === 'pull_request') {
144+
await checkPR(context.payload.pull_request);
145+
} else {
146+
for (const b of ['staging', 'main']) {
147+
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 });
148+
for (const pr of prs) await checkPR(pr);
149+
}
150+
}

0 commit comments

Comments
 (0)