Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scopes/git/ci/sync/git-host-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export interface GitHostProvider {
comment(prNumber: number, body: string): Promise<void>;

addLabel(prNumber: number, label: string): Promise<void>;

/**
* 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<void>;
}

/** The git host to use this run, or nothing plus the reason PR operations are being skipped. */
Expand Down
88 changes: 88 additions & 0 deletions scopes/git/ci/sync/github-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<!-- marker -->', '<!-- marker -->\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: '<!-- marker -->\nold report' },
]);
const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\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: '<!-- marker -->\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, '<!-- marker -->', '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: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"',
},
});
}
if (method === 'GET') {
return new Response(JSON.stringify([{ id: 99, body: '<!-- marker -->\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, '<!-- marker -->', '<!-- marker -->\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);
});
});
});
75 changes: 72 additions & 3 deletions scopes/git/ci/sync/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<any> {
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<Response> {
const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`;
const res = await this.fetchImpl(url, {
method,
headers: {
authorization: `Bearer ${this.token}`,
Expand All @@ -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<any> {
const res = await this.requestRaw(method, path, body);
return res.status === 204 ? undefined : res.json();
}

Expand Down Expand Up @@ -135,6 +149,52 @@ export class GitHubClient implements GitHostProvider {
async addLabel(prNumber: number, label: string): Promise<void> {
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<void> {
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);
}
}

/**
Expand Down Expand Up @@ -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<void> {
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;
Expand Down
142 changes: 142 additions & 0 deletions scopes/git/ci/sync/lane-sync-executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect } from 'chai';
import {
branchMirrorsOtherLaneNote,
branchMirrorsOtherLaneReason,
changedLaneComponents,
crossScopeDescription,
crossScopeMidFlightHaltReason,
crossScopeRefusal,
Expand All @@ -13,6 +14,8 @@ import {
laneHeadFingerprint,
LaneSyncExecutor,
laneSyncPrBody,
RUN_SUMMARY_MARKER,
runSummaryCommentBody,
} from './lane-sync-executor';
import { resolveSyncConfig } from './sync-config';

Expand Down Expand Up @@ -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<void>;

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');
});
});
Loading