From 17b0c806ac58d681cff8cac9fc0bacc83a212263 Mon Sep 17 00:00:00 2001 From: Luv Kapur Date: Fri, 7 Aug 2026 16:20:08 -0400 Subject: [PATCH] feat(ci sync): post a run summary on the pull request A sync run can move a component's recorded version with no file in the branch's own git diff ever changing (dependency ranges, drift). The pull request shows only the diff, so reviewers never see what the run did to the scope. After a successful export, upsert one maintained comment on the branch's PR: the components the run snapped (name + new version), and the synced branch/lane anchors. Ports GitHubClient's comment plumbing (paginated listIssueComments, marker-based upsertComment) and adds GitHostProvider.upsertComment as an optional capability - a provider that lacks it is skipped, never faked with a plain, ever-duplicating comment. No configured git host, no open PR, or a comment API failure all degrade silently or to a warning; none can fail the run. Co-Authored-By: Claude Fable 5 --- scopes/git/ci/sync/git-host-provider.ts | 8 + scopes/git/ci/sync/github-client.spec.ts | 88 +++++++++++ scopes/git/ci/sync/github-client.ts | 75 ++++++++- scopes/git/ci/sync/lane-sync-executor.spec.ts | 142 ++++++++++++++++++ scopes/git/ci/sync/lane-sync-executor.ts | 132 ++++++++++++++-- 5 files changed, 433 insertions(+), 12 deletions(-) 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.