From 9fe85a27d895eee14011f216f623c7ef78f9f2a7 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 23 Feb 2026 10:11:15 +0530 Subject: [PATCH] fix: make beta sync stack-aware --- .github/pr/fix-beta-stack-aware-sync.md | 30 ++++ .github/workflows/beta.yml | 4 + script/beta.ts | 190 ++++++++++++++++++++---- 3 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 .github/pr/fix-beta-stack-aware-sync.md diff --git a/.github/pr/fix-beta-stack-aware-sync.md b/.github/pr/fix-beta-stack-aware-sync.md new file mode 100644 index 0000000000..9d3e6de695 --- /dev/null +++ b/.github/pr/fix-beta-stack-aware-sync.md @@ -0,0 +1,30 @@ +title: fix: make beta sync conflict reporting stack-aware + +### Issue for this PR + +Closes # + +### Type of change + +- [x] Bug fix +- [ ] New feature +- [ ] Refactor / code improvement +- [ ] Documentation + +### What does this PR do? + +Makes beta sync failures stack-aware. On merge conflict, the script now reports conflicted files and likely conflicting beta PR(s), and upserts one marker-based bot comment instead of posting duplicates every run. + +### How did you verify your code works? + +- Ran `bun turbo typecheck --filter opencode` +- Ran `bunx prettier --check ".github/workflows/beta.yml" "script/beta.ts"` + +### Screenshots / recordings + +N/A + +### Checklist + +- [x] I have tested my changes locally +- [x] I have not included unrelated changes in this PR diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 20d2bc18d8..9a087fe105 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -5,6 +5,10 @@ on: schedule: - cron: "0 * * * *" +concurrency: + group: beta-sync + cancel-in-progress: false + jobs: sync: runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/script/beta.ts b/script/beta.ts index a5fb027e63..f44c476687 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -2,6 +2,8 @@ import { $ } from "bun" +const COMMENT_MARKER = "" + interface PR { number: number title: string @@ -13,24 +15,117 @@ interface FailedPR { number: number title: string reason: string + conflicts: string[] + blockers: number[] } -async function commentOnPR(prNumber: number, reason: string) { - const body = `⚠️ **Blocking Beta Release** +interface AppliedPR { + number: number + title: string + files: Set +} -This PR cannot be merged into the beta branch due to: **${reason}** +interface IssueComment { + id: number + body: string | null +} -Please resolve this issue to include this PR in the next beta release.` +function blockerNumbers(conflicts: string[], applied: AppliedPR[]) { + return applied + .map((pr) => { + const score = conflicts.filter((file) => pr.files.has(file)).length + return { number: pr.number, score } + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || b.number - a.number) + .slice(0, 3) + .map((item) => item.number) +} + +function commentBody(failed: FailedPR) { + const blockers = failed.blockers.length > 0 ? failed.blockers.map((num) => `#${num}`).join(", ") : "none identified" + const files = failed.conflicts.slice(0, 10) + const extra = failed.conflicts.length - files.length + const fileLines = files.length > 0 ? files.map((file) => `- \`${file}\``).join("\n") : "- none captured" + const extraLine = extra > 0 ? `\n- ...and ${extra} more` : "" + + return `${COMMENT_MARKER} +⚠️ **Blocking Beta Release** + +This PR cannot be merged into the beta branch. + +**Reason:** ${failed.reason} +**Likely conflicting beta PR(s):** ${blockers} + +**Conflicted files:** +${fileLines}${extraLine} + +Please rebase onto latest \`dev\` and resolve conflicts with the listed beta PR(s) before the next beta sync.` +} + +async function repositoryName() { + const repo = process.env["GITHUB_REPOSITORY"] ?? process.env["GH_REPO"] + if (repo) return repo + return (await $`gh repo view --json nameWithOwner --jq .nameWithOwner`.text()).trim() +} + +async function prFiles(prNumber: number, cache: Map>) { + const cached = cache.get(prNumber) + if (cached) return cached + const stdout = await $`gh pr view ${prNumber} --json files --jq .files[].path`.text() + const files = new Set( + stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ) + cache.set(prNumber, files) + return files +} + +async function conflictFiles() { + const stdout = await $`git diff --name-only --diff-filter=U`.text() + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +async function cleanupMergeState() { + await $`git merge --abort`.nothrow() + await $`git checkout -- .`.nothrow() + await $`git clean -fd`.nothrow() +} + +async function upsertComment(repo: string, prNumber: number, body: string) { + const stdout = await $`gh api repos/${repo}/issues/${prNumber}/comments --paginate`.text() + const comments = JSON.parse(stdout) as IssueComment[] + const existing = comments.filter((item) => item.body?.includes(COMMENT_MARKER)).at(-1) + if (!existing) { + await $`gh api repos/${repo}/issues/${prNumber}/comments --method POST --field body=${body}` + console.log(` Posted comment on PR #${prNumber}`) + return + } + if (existing.body?.trim() === body.trim()) { + console.log(` Comment already up to date on PR #${prNumber}`) + return + } + await $`gh api repos/${repo}/issues/comments/${existing.id} --method PATCH --field body=${body}` + console.log(` Updated comment on PR #${prNumber}`) +} + +async function commentOnPR(repo: string, failed: FailedPR) { + const body = commentBody(failed) try { - await $`gh pr comment ${prNumber} --body ${body}` - console.log(` Posted comment on PR #${prNumber}`) + await upsertComment(repo, failed.number, body) } catch (err) { - console.log(` Failed to post comment on PR #${prNumber}: ${err}`) + console.log(` Failed to post comment on PR #${failed.number}: ${err}`) } } async function main() { + const repo = await repositoryName() console.log("Fetching open PRs with beta label...") const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text() @@ -49,19 +144,33 @@ async function main() { console.log("Checking out beta branch...") await $`git checkout -B beta origin/dev` - const applied: number[] = [] + const applied: AppliedPR[] = [] const failed: FailedPR[] = [] + const cache = new Map>() for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) + let files = new Set() + try { + files = await prFiles(pr.number, cache) + } catch (err) { + console.log(` Failed to fetch PR files metadata: ${err}`) + } console.log(" Fetching PR head...") try { await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}` } catch (err) { console.log(` Failed to fetch: ${err}`) - failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) - await commentOnPR(pr.number, "Fetch failed") + const failure = { + number: pr.number, + title: pr.title, + reason: "Fetch failed", + conflicts: [], + blockers: [], + } + failed.push(failure) + await commentOnPR(repo, failure) continue } @@ -70,17 +179,22 @@ async function main() { await $`git merge --no-commit --no-ff pr/${pr.number}` } catch { console.log(" Failed to merge (conflicts)") - try { - await $`git merge --abort` - } catch {} - try { - await $`git checkout -- .` - } catch {} - try { - await $`git clean -fd` - } catch {} - failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) - await commentOnPR(pr.number, "Merge conflicts with dev branch") + const conflicts = await conflictFiles().catch(() => []) + const blockers = blockerNumbers(conflicts, applied) + const reason = + blockers.length > 0 + ? `Merge conflicts with beta stack (likely: ${blockers.map((num) => `#${num}`).join(", ")})` + : "Merge conflicts while applying onto beta stack" + const failure = { + number: pr.number, + title: pr.title, + reason, + conflicts, + blockers, + } + await cleanupMergeState() + failed.push(failure) + await commentOnPR(repo, failure) continue } @@ -95,8 +209,16 @@ async function main() { await $`git add -A` } catch { console.log(" Failed to stage changes") - failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) - await commentOnPR(pr.number, "Failed to stage changes") + await cleanupMergeState() + const failure = { + number: pr.number, + title: pr.title, + reason: "Staging failed", + conflicts: [], + blockers: [], + } + failed.push(failure) + await commentOnPR(repo, failure) continue } @@ -105,22 +227,34 @@ async function main() { await $`git commit -m ${commitMsg}` } catch (err) { console.log(` Failed to commit: ${err}`) - failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) - await commentOnPR(pr.number, "Failed to commit changes") + await cleanupMergeState() + const failure = { + number: pr.number, + title: pr.title, + reason: "Commit failed", + conflicts: [], + blockers: [], + } + failed.push(failure) + await commentOnPR(repo, failure) continue } console.log(" Applied successfully") - applied.push(pr.number) + applied.push({ number: pr.number, title: pr.title, files }) } console.log("\n--- Summary ---") console.log(`Applied: ${applied.length} PRs`) - applied.forEach((num) => console.log(` - PR #${num}`)) + applied.forEach((pr) => console.log(` - PR #${pr.number}`)) if (failed.length > 0) { console.log(`Failed: ${failed.length} PRs`) - failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) + failed.forEach((item) => { + const blockers = + item.blockers.length > 0 ? ` (likely with ${item.blockers.map((num) => `#${num}`).join(", ")})` : "" + console.log(` - PR #${item.number}: ${item.reason}${blockers}`) + }) throw new Error(`${failed.length} PR(s) failed to merge`) }