diff --git a/scopes/git/ci/sync/git-host-provider.ts b/scopes/git/ci/sync/git-host-provider.ts index ae6705add384..1e4102665750 100644 --- a/scopes/git/ci/sync/git-host-provider.ts +++ b/scopes/git/ci/sync/git-host-provider.ts @@ -33,6 +33,14 @@ export interface GitHostProvider { comment(prNumber: number, body: string): Promise; addLabel(prNumber: number, label: string): Promise; + + /** + * Update the comment containing `marker` in place, or post a new one when none exists yet + * (`options.createIfAbsent` defaults to true). Optional: a provider that doesn't implement this + * is treated as not supporting the run-summary surface — callers skip rather than fall back to a + * plain, never-updated `comment`, which would leave one stale copy per push. + */ + upsertComment?(prNumber: number, marker: string, body: string, options?: { createIfAbsent?: boolean }): Promise; } /** The git host to use this run, or nothing plus the reason PR operations are being skipped. */ diff --git a/scopes/git/ci/sync/github-client.spec.ts b/scopes/git/ci/sync/github-client.spec.ts index 93ab4d18a0b4..964dec694364 100644 --- a/scopes/git/ci/sync/github-client.spec.ts +++ b/scopes/git/ci/sync/github-client.spec.ts @@ -171,4 +171,92 @@ describe('GitHubClient', () => { const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl: fakeFetch }); expect(await client.findPrByBranch('lane-x')).to.equal(undefined); }); + + describe('upsertComment', () => { + // named `upsertComment`, matching `GitHostProvider.upsertComment` exactly — see the method's + // own doc comment for why a differently-named method would be a silent-no-op trap. + function fakeFetchOver(existingComments: Array<{ id: number; body: string }>) { + const calls: Array<{ url: string; init: any }> = []; + const fetchImpl = (async (url: any, init: any) => { + calls.push({ url: String(url), init }); + const method = init?.method ?? 'GET'; + if (method === 'GET') { + return new Response(JSON.stringify(existingComments), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + return { calls, fetchImpl }; + } + + it('posts a new comment when no marked comment exists', async () => { + const { calls, fetchImpl } = fakeFetchOver([]); + const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl }); + await client.upsertComment(7, '', '\nbody'); + const posts = calls.filter((c) => c.init?.method === 'POST'); + expect(posts).to.have.lengthOf(1); + expect(posts[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments'); + }); + + it('patches the marked comment in place, and never posts a second one', async () => { + const { calls, fetchImpl } = fakeFetchOver([ + { id: 42, body: 'unrelated comment' }, + { id: 99, body: '\nold report' }, + ]); + const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl }); + await client.upsertComment(7, '', '\nnew report'); + const patches = calls.filter((c) => c.init?.method === 'PATCH'); + expect(patches).to.have.lengthOf(1); + expect(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99'); + expect(JSON.parse(patches[0].init.body)).to.deep.equal({ body: '\nnew report' }); + expect(calls.some((c) => c.init?.method === 'POST')).to.equal(false); + }); + + it('skips silently when createIfAbsent is false and no marked comment exists', async () => { + const { calls, fetchImpl } = fakeFetchOver([]); + const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl }); + await client.upsertComment(7, '', 'cleared', { createIfAbsent: false }); + expect(calls.some((c) => c.init?.method === 'POST' || c.init?.method === 'PATCH')).to.equal(false); + }); + + it('lists at per_page=100 and follows Link-header pagination to find a comment past page 1', async () => { + // GitHub defaults to per_page=30; a naive single-page list would miss this comment and post a + // duplicate report on every push instead of updating it. + const calls: Array<{ url: string; init: any }> = []; + const fetchImpl = (async (url: any, init: any) => { + calls.push({ url: String(url), init }); + const method = init?.method ?? 'GET'; + if (method === 'GET' && !String(url).includes('page=2')) { + return new Response(JSON.stringify([{ id: 1, body: 'unrelated' }]), { + status: 200, + headers: { + 'content-type': 'application/json', + link: '; rel="next"', + }, + }); + } + if (method === 'GET') { + return new Response(JSON.stringify([{ id: 99, body: '\nold report' }]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl }); + await client.upsertComment(7, '', '\nnew report'); + + const gets = calls.filter((c) => (c.init?.method ?? 'GET') === 'GET'); + expect(gets).to.have.lengthOf(2); + expect(gets[0].url).to.include('per_page=100'); + expect(gets[1].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2'); + const patches = calls.filter((c) => c.init?.method === 'PATCH'); + expect(patches).to.have.lengthOf(1); + expect(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99'); + // the found-on-page-2 comment was updated, not duplicated + expect(calls.some((c) => c.init?.method === 'POST')).to.equal(false); + }); + }); }); diff --git a/scopes/git/ci/sync/github-client.ts b/scopes/git/ci/sync/github-client.ts index 82a07c1e535f..9f3e3c3e419c 100644 --- a/scopes/git/ci/sync/github-client.ts +++ b/scopes/git/ci/sync/github-client.ts @@ -32,6 +32,13 @@ export function isGitHubRemote(remoteUrl: string): boolean { const API = 'https://api.github.com'; +/** The `rel="next"` URL out of a GitHub `Link` response header, or undefined on the last page. */ +function nextPageUrl(linkHeader: string | null): string | undefined { + if (!linkHeader) return undefined; + const next = linkHeader.split(',').find((part) => part.includes('rel="next"')); + return next?.match(/<([^>]+)>/)?.[1]; +} + /** Warning sink; a plain callback rather than a `Logger` so this module needs no logger aspect. */ export type WarnFn = (message: string) => void; @@ -85,8 +92,10 @@ export class GitHubClient implements GitHostProvider { return Boolean(this.token && this.repo); } - private async request(method: string, path: string, body?: unknown): Promise { - const res = await this.fetchImpl(`${API}/repos/${this.repo}${path}`, { + /** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */ + private async requestRaw(method: string, pathOrUrl: string, body?: unknown): Promise { + const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`; + const res = await this.fetchImpl(url, { method, headers: { authorization: `Bearer ${this.token}`, @@ -98,8 +107,13 @@ export class GitHubClient implements GitHostProvider { }); if (!res.ok) { const text = await res.text().catch(() => ''); - throw new Error(`GitHub API ${method} ${path} failed: ${res.status} ${text}`); + throw new Error(`GitHub API ${method} ${pathOrUrl} failed: ${res.status} ${text}`); } + return res; + } + + private async request(method: string, path: string, body?: unknown): Promise { + const res = await this.requestRaw(method, path, body); return res.status === 204 ? undefined : res.json(); } @@ -135,6 +149,52 @@ export class GitHubClient implements GitHostProvider { async addLabel(prNumber: number, label: string): Promise { await this.request('POST', `/issues/${prNumber}/labels`, { labels: [label] }); } + + /** + * Every comment on the PR, across all pages. GitHub defaults `per_page` to 30; a marked comment + * living past page 1 would otherwise read as absent, and `upsertComment` would post a duplicate on + * every push instead of updating the existing one. Follows the `Link: rel="next"` header — a page + * with no such link is the last one. + */ + private async listIssueComments(prNumber: number): Promise<{ id: number; body: string }[]> { + const comments: { id: number; body: string }[] = []; + let next: string | undefined = `/issues/${prNumber}/comments?per_page=100`; + // Defensive cap: 20 pages (2,000 comments) is far past any real PR; without it a malformed or + // adversarial `Link` header could loop this call forever. + for (let page = 0; next && page < 20; page += 1) { + const res: Response = await this.requestRaw('GET', next); + const body = (await res.json()) as any[]; + comments.push(...body.map((c: any) => ({ id: c.id, body: c.body ?? '' }))); + next = nextPageUrl(res.headers.get('link')); + } + return comments; + } + + /** + * Find the comment carrying `marker` (an HTML comment embedded in the body) and replace its body, + * or post `body` as a new comment when none exists and `options.createIfAbsent` is not false. + * GitHub's comment PATCH endpoint is `/issues/comments/{id}`, not `/issues/{pr}/comments/{id}` — + * a comment id is unique per repository, not scoped under the issue/PR that carries it. + * + * Named to match `GitHostProvider.upsertComment` exactly (not e.g. `upsertIssueComment`): a + * `GitHubClient` registered directly as a provider must satisfy the interface's optional method + * under its real name, or callers that feature-test via `gitHost.upsertComment` would silently + * treat a fully-capable client as unsupported. + */ + async upsertComment( + prNumber: number, + marker: string, + body: string, + options: { createIfAbsent?: boolean } = {} + ): Promise { + const existing = (await this.listIssueComments(prNumber)).find((c) => c.body.includes(marker)); + if (existing) { + await this.request('PATCH', `/issues/comments/${existing.id}`, { body }); + return; + } + if (options.createIfAbsent === false) return; + await this.comment(prNumber, body); + } } /** @@ -191,6 +251,15 @@ export class GitHubHostProvider implements GitHostProvider { return this.requireClient().addLabel(prNumber, label); } + async upsertComment( + prNumber: number, + marker: string, + body: string, + options?: { createIfAbsent?: boolean } + ): Promise { + return this.requireClient().upsertComment(prNumber, marker, body, options); + } + private resolveClient(remoteUrl?: string): GitHubClient | undefined { if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning); return this.client; diff --git a/scopes/git/ci/sync/lane-sync-executor.spec.ts b/scopes/git/ci/sync/lane-sync-executor.spec.ts index 60082a2d2be7..e2ce5b92d766 100644 --- a/scopes/git/ci/sync/lane-sync-executor.spec.ts +++ b/scopes/git/ci/sync/lane-sync-executor.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { branchMirrorsOtherLaneNote, branchMirrorsOtherLaneReason, + changedLaneComponents, crossScopeDescription, crossScopeMidFlightHaltReason, crossScopeRefusal, @@ -13,6 +14,8 @@ import { laneHeadFingerprint, LaneSyncExecutor, laneSyncPrBody, + RUN_SUMMARY_MARKER, + runSummaryCommentBody, } from './lane-sync-executor'; import { resolveSyncConfig } from './sync-config'; @@ -387,3 +390,142 @@ describe('isProtectedBranch', () => { expect(isProtectedBranch('my-lane', 'develop', 'bit-sync/main')).to.equal(false); }); }); + +describe('changedLaneComponents', () => { + it('includes a component new to the lane, and one whose head moved', () => { + const before = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')]; + const after = [ + comp('acme.shop/comp1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2'), + comp('acme.shop/comp2', 'ccccccccccccccccccccccccccccccccccccccc3'), + ]; + const changed = changedLaneComponents(before, after); + expect(changed.map((c) => c.id.toStringWithoutVersion())).to.deep.equal(['acme.shop/comp1', 'acme.shop/comp2']); + }); + + it('excludes a component whose head did not move', () => { + const stable = comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'); + expect(changedLaneComponents([stable], [stable])).to.deep.equal([]); + }); + + it('treats an undefined "before" (never seen this lane) as everything being new', () => { + const after = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')]; + expect(changedLaneComponents(undefined, after)).to.deep.equal(after); + }); +}); + +describe('runSummaryCommentBody', () => { + it('carries the marker, the changed components, and the branch/lane anchors', () => { + const body = runSummaryCommentBody({ + laneIdStr: 'acme.shop/my-lane', + laneUrl: 'https://bit.cloud/acme/shop/~lane/my-lane', + branch: 'my-lane', + branchTipSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2', + changed: [comp('acme.shop/comp1', 'ccccccccccccccccccccccccccccccccccccccc3')], + }); + expect(body).to.include(RUN_SUMMARY_MARKER); + expect(body).to.include('acme.shop/comp1` @ `ccccccccc'); + expect(body).to.include('branch: `my-lane` @ `aaaaaaaaa`'); + expect(body).to.include('lane: `acme.shop/my-lane` @ `bbbbbbbbb`'); + }); + + it('says plainly that nothing changed, rather than an empty list', () => { + const body = runSummaryCommentBody({ + laneIdStr: 'acme.shop/my-lane', + laneUrl: 'https://bit.cloud/acme/shop/~lane/my-lane', + branch: 'my-lane', + branchTipSha: undefined, + laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2', + changed: [], + }); + expect(body).to.include('none — nothing on the lane changed this run'); + expect(body).to.not.include('@ `undefined'); + }); +}); + +// The comment is a pure surface effect: it must never gate the run's own outcome, and must degrade +// silently through every "nothing to comment on" state a real host, PR or branch can be in. +describe('postRunSummaryComment', () => { + const BEFORE = [comp('acme.shop/comp1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')]; + const AFTER = [comp('acme.shop/comp1', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2')]; + + function executorWith(gitHost: any, warnings: string[] = []) { + const executor = new LaneSyncExecutor({ + lanes: {} as any, + ci: {} as any, + logger: { console: () => {}, consoleWarning: (msg: string) => warnings.push(msg), error: () => {} } as any, + gitHost, + cfg: resolveSyncConfig({}), + defaultScope: 'acme.shop', + }); + (executor as any).currentBranchTip = async () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'; + return executor; + } + + const call = (executor: LaneSyncExecutor) => + (executor as any).postRunSummaryComment({ + target: { hostScope: 'acme.shop', name: 'my-lane' }, + laneIdStr: 'acme.shop/my-lane', + branch: 'my-lane', + laneHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2', + preComponents: BEFORE, + postComponents: AFTER, + }) as Promise; + + it('upserts the marked comment on the open PR when the host supports it', async () => { + const calls: any[] = []; + const gitHost = { + findPrByBranch: async () => ({ number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }), + upsertComment: async (prNumber: number, marker: string, body: string) => { + calls.push({ prNumber, marker, body }); + }, + }; + await call(executorWith(gitHost)); + expect(calls).to.have.lengthOf(1); + expect(calls[0].prNumber).to.equal(7); + expect(calls[0].marker).to.equal(RUN_SUMMARY_MARKER); + expect(calls[0].body).to.include('acme.shop/comp1` @ `bbbbbbbbb'); + }); + + it('is a no-op with no configured git host', async () => { + await call(executorWith(undefined)); // would throw on any host call; nothing to assert but "did not throw" + }); + + it('is a no-op when the host does not implement upsertComment', async () => { + const calls: any[] = []; + const gitHost = { + findPrByBranch: async () => { + calls.push('findPrByBranch'); + return { number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }; + }, + }; + await call(executorWith(gitHost)); + // upsertComment is feature-tested BEFORE the PR lookup — an unsupported host must not even look up the PR. + expect(calls).to.deep.equal([]); + }); + + it('is a no-op when the branch has no open PR yet', async () => { + const calls: any[] = []; + const gitHost = { + findPrByBranch: async () => undefined, + upsertComment: async () => { + calls.push('upsertComment'); + }, + }; + await call(executorWith(gitHost)); + expect(calls).to.deep.equal([]); + }); + + it('warns and does not throw when the comment API call fails', async () => { + const warnings: string[] = []; + const gitHost = { + findPrByBranch: async () => ({ number: 7, htmlUrl: 'https://example.test/pr/7', labels: [] }), + upsertComment: async () => { + throw new Error('rate limited'); + }, + }; + await call(executorWith(gitHost, warnings)); + expect(warnings.join('\n')).to.contain('Could not post the run-summary comment'); + expect(warnings.join('\n')).to.contain('rate limited'); + }); +}); diff --git a/scopes/git/ci/sync/lane-sync-executor.ts b/scopes/git/ci/sync/lane-sync-executor.ts index 4be0260411b0..bcd84836ae05 100644 --- a/scopes/git/ci/sync/lane-sync-executor.ts +++ b/scopes/git/ci/sync/lane-sync-executor.ts @@ -51,6 +51,12 @@ export const HALT_SUMMARY_PREFIX = 'HALTED'; */ export const REFUSED_SUMMARY_PREFIX = 'REFUSED'; +/** + * An HTML comment embedded in the run-summary comment's body, so `upsertComment` finds and replaces + * THIS comment on a later run rather than posting a duplicate summary on every push. + */ +export const RUN_SUMMARY_MARKER = ''; + export type LaneSyncDeps = { lanes: LanesMain; /** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */ @@ -445,7 +451,7 @@ export class LaneSyncExecutor { pr, }); case 'export-branch': - return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch }); + return this.executeExportBranch({ target, laneIdStr, branch, defaultBranch, preExportLane: remoteLane }); case 'merge-diverged': return this.executeMergeDiverged({ target, laneIdStr, branch, defaultBranch }); case 'close-pr': @@ -596,11 +602,14 @@ export class LaneSyncExecutor { laneIdStr, branch, defaultBranch, + preExportLane, }: { target: LaneTarget; laneIdStr: string; branch: string; defaultBranch: string; + /** The lane's content BEFORE this export — the baseline the run-summary comment diffs against. */ + preExportLane: LaneData | undefined; }): Promise { const { logger } = this.deps; const laneName = target.name; @@ -622,8 +631,8 @@ export class LaneSyncExecutor { }); } - const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch); - if (!laneHead) { + const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch); + if (!recorded) { return await this.executeHalt({ laneName, laneIdStr, @@ -632,6 +641,16 @@ export class LaneSyncExecutor { pr: await this.findPr(branch), }); } + const { laneHead, remoteLane } = recorded; + // Best-effort: a comment failure must never undo a git/lane pair that already converged. + await this.postRunSummaryComment({ + target, + laneIdStr, + branch, + laneHead, + preComponents: preExportLane?.components, + postComponents: remoteLane.components, + }); return `${laneName} -> export-branch (lane ${laneIdStr} @ ${laneHead.slice(0, 9)}, branch ${branch} updated)`; } finally { await this.restoreWorkspace(defaultBranch); @@ -740,13 +759,13 @@ export class LaneSyncExecutor { } // ---- step 3: record the new lane head on the branch -------------------------------------- - const laneHead = await this.recordLaneHeadOnBranch(target, laneIdStr, branch); - if (!laneHead) { + const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch); + if (!recorded) { return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`); } return ( `${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` + - `${laneHead.slice(0, 9)}, branch ${branch} updated)` + `${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)` ); } catch (e: any) { // Something unforeseen — halt anyway: no single lane may abort the rest of the run. @@ -788,18 +807,19 @@ export class LaneSyncExecutor { /** * Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it, * so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push. - * Returns undefined when the lane can no longer be read, in which case the caller halts. + * Returns undefined when the lane can no longer be read, in which case the caller halts. The + * re-queried lane is also the caller's only cheap source of POST-export component versions. */ private async recordLaneHeadOnBranch( target: LaneTarget, laneIdStr: string, branch: string - ): Promise { + ): Promise<{ laneHead: string; remoteLane: LaneData } | undefined> { const remoteLane = await this.getRemoteLane(target); if (!remoteLane) return undefined; const laneHead = laneHeadFingerprint(remoteLane.components); await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead)); - return laneHead; + return { laneHead, remoteLane }; } /** @@ -1195,6 +1215,51 @@ export class LaneSyncExecutor { } } + /** + * Post (or update) ONE maintained comment on the branch's PR: what this export did to the scope — + * the components it snapped and the synced branch/lane anchors — information the branch's git diff + * cannot show, since the snap step alone can move dependency ranges the PR's files never touched. + * Best-effort throughout: no configured git host, no open PR yet, or a provider that doesn't + * implement `upsertComment` are all normal states, not failures — the git/lane pair already + * converged without this comment. A comment API error warns and never fails the run. + */ + private async postRunSummaryComment({ + target, + laneIdStr, + branch, + laneHead, + preComponents, + postComponents, + }: { + target: LaneTarget; + laneIdStr: string; + branch: string; + laneHead: string; + preComponents: LaneData['components'] | undefined; + postComponents: LaneData['components']; + }): Promise { + const { gitHost, logger } = this.deps; + if (!gitHost?.upsertComment) return; + const pr = await this.findPr(branch); + if (!pr) return; + // Same URL shape as the lane-sync PR body (line ~1249): the scope that HOSTS the lane, not + // necessarily `defaultScope`. + const laneUrl = `https://${getCloudDomain()}/${target.hostScope.replace('.', '/')}/~lane/${target.name}`; + const body = runSummaryCommentBody({ + laneIdStr, + laneUrl, + branch, + branchTipSha: await this.currentBranchTip(branch), + laneHead, + changed: changedLaneComponents(preComponents, postComponents), + }); + try { + await gitHost.upsertComment(pr.number, RUN_SUMMARY_MARKER, body); + } catch (e: any) { + logger.consoleWarning(`Could not post the run-summary comment on ${branch}'s PR: ${e?.message || e}`); + } + } + /** * The subject of the newest commit on the branch that isn't one of our own sync commits — it's * the developer's own description of the change, and becomes the snap message on the lane. @@ -1328,6 +1393,55 @@ export function laneSyncPrBody({ ].join('\n'); } +/** + * The lane components this export actually changed: new on the lane, or moved to a new head. Diffed + * by id against the lane's content BEFORE the export — `LaneData.head` is the snap hash bit assigns a + * lane component, so comparing it (not looking at git) is the only way to name what the snap did. + */ +export function changedLaneComponents( + before: LaneData['components'] | undefined, + after: LaneData['components'] +): LaneData['components'] { + const beforeHeads = new Map((before ?? []).map((comp) => [comp.id.toStringWithoutVersion(), comp.head])); + return after.filter((comp) => beforeHeads.get(comp.id.toStringWithoutVersion()) !== comp.head); +} + +/** + * What a sync run did to the scope — information the branch's own git diff cannot show, since the + * snap step can move a component's recorded version with no file in the PR ever changing. Kept to one + * maintained comment (`RUN_SUMMARY_MARKER` + `upsertComment`) rather than one post per push. + */ +export function runSummaryCommentBody({ + laneIdStr, + laneUrl, + branch, + branchTipSha, + laneHead, + changed, +}: { + laneIdStr: string; + laneUrl: string; + branch: string; + branchTipSha: string | undefined; + laneHead: string; + changed: LaneData['components']; +}): string { + const listed = capEntries( + changed.map((comp) => `- \`${comp.id.toStringWithoutVersion()}\` @ \`${comp.head.slice(0, 9)}\``), + '- ' + ).join('\n'); + return [ + RUN_SUMMARY_MARKER, + `**bit ci sync** synced this branch with lane [\`${laneIdStr}\`](${laneUrl}).`, + '', + `Components snapped (${changed.length}):`, + listed || '_none — nothing on the lane changed this run_', + '', + `- branch: \`${branch}\`${branchTipSha ? ` @ \`${branchTipSha.slice(0, 9)}\`` : ''}`, + `- lane: \`${laneIdStr}\` @ \`${laneHead.slice(0, 9)}\``, + ].join('\n'); +} + /** * Single-quote a value interpolated into a copy-pasteable shell command. Lane names may contain `$` and * `!`, and a configured branch name may contain anything git accepts as a ref — including a quote.