@teambit/ci 1.0.507 → 1.0.508

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/ci.main.runtime.d.ts +3 -2
  2. package/dist/ci.main.runtime.js +5 -6
  3. package/dist/ci.main.runtime.js.map +1 -1
  4. package/dist/{preview-1787147173446.js → preview-1787160027560.js} +1 -1
  5. package/dist/sync/git-host-provider.d.ts +9 -0
  6. package/dist/sync/git-host-provider.js.map +1 -1
  7. package/dist/sync/github-client.d.ts +28 -0
  8. package/dist/sync/github-client.js +81 -3
  9. package/dist/sync/github-client.js.map +1 -1
  10. package/dist/sync/github-client.spec.js +249 -0
  11. package/dist/sync/github-client.spec.js.map +1 -1
  12. package/dist/sync/lane-sync-executor.d.ts +47 -3
  13. package/dist/sync/lane-sync-executor.js +191 -39
  14. package/dist/sync/lane-sync-executor.js.map +1 -1
  15. package/dist/sync/lane-sync-executor.spec.js +336 -0
  16. package/dist/sync/lane-sync-executor.spec.js.map +1 -1
  17. package/dist/sync/sync-planner.d.ts +12 -2
  18. package/dist/sync/sync-planner.js +17 -8
  19. package/dist/sync/sync-planner.js.map +1 -1
  20. package/dist/sync/sync-planner.spec.js +64 -0
  21. package/dist/sync/sync-planner.spec.js.map +1 -1
  22. package/dist/sync/sync-state.d.ts +34 -3
  23. package/dist/sync/sync-state.js +61 -3
  24. package/dist/sync/sync-state.js.map +1 -1
  25. package/dist/sync/sync-state.spec.js +54 -0
  26. package/dist/sync/sync-state.spec.js.map +1 -1
  27. package/package.json +15 -15
  28. package/sync/git-host-provider.ts +8 -0
  29. package/sync/github-client.spec.ts +160 -0
  30. package/sync/github-client.ts +90 -3
  31. package/sync/lane-sync-executor.spec.ts +291 -0
  32. package/sync/lane-sync-executor.ts +233 -50
  33. package/sync/sync-planner.spec.ts +41 -0
  34. package/sync/sync-planner.ts +31 -10
  35. package/sync/sync-state.spec.ts +48 -0
  36. package/sync/sync-state.ts +104 -4
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teambit/ci",
3
- "version": "1.0.507",
3
+ "version": "1.0.508",
4
4
  "homepage": "https://bit.cloud/teambit/git/ci",
5
5
  "main": "dist/index.js",
6
6
  "componentId": {
7
7
  "scope": "teambit.git",
8
8
  "name": "ci",
9
- "version": "1.0.507"
9
+ "version": "1.0.508"
10
10
  },
11
11
  "dependencies": {
12
12
  "chalk": "4.1.2",
@@ -28,19 +28,19 @@
28
28
  "@teambit/legacy.scope": "0.0.148",
29
29
  "@teambit/legacy.bit-map": "0.0.205",
30
30
  "@teambit/toolbox.crypto.sha1": "0.0.20",
31
- "@teambit/cli": "0.0.1370",
32
- "@teambit/logger": "0.0.1463",
33
- "@teambit/workspace": "1.0.1112",
34
- "@teambit/builder": "1.0.1112",
35
- "@teambit/checkout": "1.0.1113",
36
- "@teambit/config-merger": "0.0.979",
37
- "@teambit/dependency-resolver": "1.0.1112",
38
- "@teambit/export": "1.0.1112",
39
- "@teambit/importer": "1.0.1112",
40
- "@teambit/lanes": "1.0.1133",
41
- "@teambit/objects": "0.0.619",
42
- "@teambit/snapping": "1.0.1112",
43
- "@teambit/status": "1.0.1137"
31
+ "@teambit/cli": "0.0.1371",
32
+ "@teambit/logger": "0.0.1464",
33
+ "@teambit/workspace": "1.0.1113",
34
+ "@teambit/builder": "1.0.1113",
35
+ "@teambit/checkout": "1.0.1114",
36
+ "@teambit/config-merger": "0.0.980",
37
+ "@teambit/dependency-resolver": "1.0.1113",
38
+ "@teambit/export": "1.0.1113",
39
+ "@teambit/importer": "1.0.1113",
40
+ "@teambit/lanes": "1.0.1134",
41
+ "@teambit/objects": "0.0.620",
42
+ "@teambit/snapping": "1.0.1113",
43
+ "@teambit/status": "1.0.1138"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/lodash": "4.14.165",
@@ -33,6 +33,14 @@ export interface GitHostProvider {
33
33
  comment(prNumber: number, body: string): Promise<void>;
34
34
 
35
35
  addLabel(prNumber: number, label: string): Promise<void>;
36
+
37
+ /**
38
+ * Update the comment containing `marker` in place, or post a new one when none exists yet
39
+ * (`options.createIfAbsent` defaults to true). Optional: a provider that doesn't implement this
40
+ * is treated as not supporting the run-summary surface — callers skip rather than fall back to a
41
+ * plain, never-updated `comment`, which would leave one stale copy per push.
42
+ */
43
+ upsertComment?(prNumber: number, marker: string, body: string, options?: { createIfAbsent?: boolean }): Promise<void>;
36
44
  }
37
45
 
38
46
  /** The git host to use this run, or nothing plus the reason PR operations are being skipped. */
@@ -171,4 +171,164 @@ describe('GitHubClient', () => {
171
171
  const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl: fakeFetch });
172
172
  expect(await client.findPrByBranch('lane-x')).to.equal(undefined);
173
173
  });
174
+
175
+ describe('upsertComment', () => {
176
+ // named `upsertComment`, matching `GitHostProvider.upsertComment` exactly — see the method's
177
+ // own doc comment for why a differently-named method would be a silent-no-op trap.
178
+ function fakeFetchOver(existingComments: Array<{ id: number; body: string }>) {
179
+ const calls: Array<{ url: string; init: any }> = [];
180
+ const fetchImpl = (async (url: any, init: any) => {
181
+ calls.push({ url: String(url), init });
182
+ const method = init?.method ?? 'GET';
183
+ if (method === 'GET') {
184
+ return new Response(JSON.stringify(existingComments), {
185
+ status: 200,
186
+ headers: { 'content-type': 'application/json' },
187
+ });
188
+ }
189
+ return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } });
190
+ }) as typeof fetch;
191
+ return { calls, fetchImpl };
192
+ }
193
+
194
+ it('posts a new comment when no marked comment exists', async () => {
195
+ const { calls, fetchImpl } = fakeFetchOver([]);
196
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
197
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody');
198
+ const posts = calls.filter((c) => c.init?.method === 'POST');
199
+ expect(posts).to.have.lengthOf(1);
200
+ expect(posts[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments');
201
+ });
202
+
203
+ it('patches the marked comment in place, and never posts a second one', async () => {
204
+ const { calls, fetchImpl } = fakeFetchOver([
205
+ { id: 42, body: 'unrelated comment' },
206
+ { id: 99, body: '<!-- marker -->\nold report' },
207
+ ]);
208
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
209
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
210
+ const patches = calls.filter((c) => c.init?.method === 'PATCH');
211
+ expect(patches).to.have.lengthOf(1);
212
+ expect(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99');
213
+ expect(JSON.parse(patches[0].init.body)).to.deep.equal({ body: '<!-- marker -->\nnew report' });
214
+ expect(calls.some((c) => c.init?.method === 'POST')).to.equal(false);
215
+ });
216
+
217
+ it('skips silently when createIfAbsent is false and no marked comment exists', async () => {
218
+ const { calls, fetchImpl } = fakeFetchOver([]);
219
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
220
+ await client.upsertComment(7, '<!-- marker -->', 'cleared', { createIfAbsent: false });
221
+ expect(calls.some((c) => c.init?.method === 'POST' || c.init?.method === 'PATCH')).to.equal(false);
222
+ });
223
+
224
+ it('lists at per_page=100 and follows Link-header pagination to find a comment past page 1', async () => {
225
+ // GitHub defaults to per_page=30; a naive single-page list would miss this comment and post a
226
+ // duplicate report on every push instead of updating it.
227
+ const calls: Array<{ url: string; init: any }> = [];
228
+ const fetchImpl = (async (url: any, init: any) => {
229
+ calls.push({ url: String(url), init });
230
+ const method = init?.method ?? 'GET';
231
+ if (method === 'GET' && !String(url).includes('page=2')) {
232
+ return new Response(JSON.stringify([{ id: 1, body: 'unrelated' }]), {
233
+ status: 200,
234
+ headers: {
235
+ 'content-type': 'application/json',
236
+ link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"',
237
+ },
238
+ });
239
+ }
240
+ if (method === 'GET') {
241
+ return new Response(JSON.stringify([{ id: 99, body: '<!-- marker -->\nold report' }]), {
242
+ status: 200,
243
+ headers: { 'content-type': 'application/json' },
244
+ });
245
+ }
246
+ return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } });
247
+ }) as typeof fetch;
248
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
249
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
250
+
251
+ const gets = calls.filter((c) => (c.init?.method ?? 'GET') === 'GET');
252
+ expect(gets).to.have.lengthOf(2);
253
+ expect(gets[0].url).to.include('per_page=100');
254
+ expect(gets[1].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2');
255
+ const patches = calls.filter((c) => c.init?.method === 'PATCH');
256
+ expect(patches).to.have.lengthOf(1);
257
+ expect(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99');
258
+ // the found-on-page-2 comment was updated, not duplicated
259
+ expect(calls.some((c) => c.init?.method === 'POST')).to.equal(false);
260
+ });
261
+
262
+ it('refuses to follow a Link header that leaves api.github.com — the token stays home', async () => {
263
+ // The next-page URL is response data, and requestRaw attaches the bearer token to whatever URL
264
+ // it is handed — an off-host "next" link must read as "last page", never as a request target.
265
+ const calls: Array<{ url: string; init: any }> = [];
266
+ const fetchImpl = (async (url: any, init: any) => {
267
+ calls.push({ url: String(url), init });
268
+ const method = init?.method ?? 'GET';
269
+ if (method === 'GET') {
270
+ return new Response(JSON.stringify([{ id: 1, body: 'unrelated' }]), {
271
+ status: 200,
272
+ headers: {
273
+ 'content-type': 'application/json',
274
+ link: '<https://evil.test/repos/acme/shop/issues/7/comments?page=2>; rel="next"',
275
+ },
276
+ });
277
+ }
278
+ return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } });
279
+ }) as typeof fetch;
280
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
281
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody');
282
+
283
+ expect(calls.some((c) => c.url.includes('evil.test'))).to.equal(false);
284
+ const gets = calls.filter((c) => (c.init?.method ?? 'GET') === 'GET');
285
+ expect(gets, 'the off-host link is the last page, not a loop').to.have.lengthOf(1);
286
+ });
287
+
288
+ it('stops paginating as soon as the marked comment is seen', async () => {
289
+ const calls: Array<{ url: string; init: any }> = [];
290
+ const fetchImpl = (async (url: any, init: any) => {
291
+ calls.push({ url: String(url), init });
292
+ const method = init?.method ?? 'GET';
293
+ if (method === 'GET') {
294
+ return new Response(JSON.stringify([{ id: 99, body: '<!-- marker -->\nold report' }]), {
295
+ status: 200,
296
+ headers: {
297
+ 'content-type': 'application/json',
298
+ link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"',
299
+ },
300
+ });
301
+ }
302
+ return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } });
303
+ }) as typeof fetch;
304
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
305
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
306
+
307
+ const gets = calls.filter((c) => (c.init?.method ?? 'GET') === 'GET');
308
+ expect(gets, 'the match on page 1 makes page 2 pointless').to.have.lengthOf(1);
309
+ expect(calls.filter((c) => c.init?.method === 'PATCH')).to.have.lengthOf(1);
310
+ });
311
+
312
+ it('exhausting the page budget throws instead of posting a duplicate', async () => {
313
+ // "Ran out of budget" must not read as "absent": a POST here would add a duplicate report on
314
+ // every later push. The throw surfaces through the caller's existing warn-and-skip path.
315
+ const calls: Array<{ url: string; init: any }> = [];
316
+ const fetchImpl = (async (url: any, init: any) => {
317
+ calls.push({ url: String(url), init });
318
+ return new Response(JSON.stringify([{ id: 1, body: 'unrelated' }]), {
319
+ status: 200,
320
+ headers: {
321
+ 'content-type': 'application/json',
322
+ link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"',
323
+ },
324
+ });
325
+ }) as typeof fetch;
326
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl });
327
+ let thrown: Error | undefined;
328
+ await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody').catch((e) => (thrown = e));
329
+
330
+ expect(thrown, 'the budget exhaustion is an error, not an absence').to.be.an('error');
331
+ expect(calls.some((c) => c.init?.method === 'POST')).to.equal(false);
332
+ });
333
+ });
174
334
  });
@@ -32,6 +32,23 @@ export function isGitHubRemote(remoteUrl: string): boolean {
32
32
 
33
33
  const API = 'https://api.github.com';
34
34
 
35
+ /**
36
+ * The `rel="next"` URL out of a GitHub `Link` response header, or undefined on the last page. The
37
+ * header is response data, and `requestRaw` attaches the bearer token to whatever URL it is handed —
38
+ * so a next link whose origin is not the API's reads as "last page", never as a request target.
39
+ */
40
+ function nextPageUrl(linkHeader: string | null): string | undefined {
41
+ if (!linkHeader) return undefined;
42
+ const next = linkHeader.split(',').find((part) => part.includes('rel="next"'));
43
+ const url = next?.match(/<([^>]+)>/)?.[1];
44
+ if (!url) return undefined;
45
+ try {
46
+ return new URL(url).origin === API ? url : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
35
52
  /** Warning sink; a plain callback rather than a `Logger` so this module needs no logger aspect. */
36
53
  export type WarnFn = (message: string) => void;
37
54
 
@@ -85,8 +102,10 @@ export class GitHubClient implements GitHostProvider {
85
102
  return Boolean(this.token && this.repo);
86
103
  }
87
104
 
88
- private async request(method: string, path: string, body?: unknown): Promise<any> {
89
- const res = await this.fetchImpl(`${API}/repos/${this.repo}${path}`, {
105
+ /** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */
106
+ private async requestRaw(method: string, pathOrUrl: string, body?: unknown): Promise<Response> {
107
+ const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`;
108
+ const res = await this.fetchImpl(url, {
90
109
  method,
91
110
  headers: {
92
111
  authorization: `Bearer ${this.token}`,
@@ -98,8 +117,13 @@ export class GitHubClient implements GitHostProvider {
98
117
  });
99
118
  if (!res.ok) {
100
119
  const text = await res.text().catch(() => '');
101
- throw new Error(`GitHub API ${method} ${path} failed: ${res.status} ${text}`);
120
+ throw new Error(`GitHub API ${method} ${pathOrUrl} failed: ${res.status} ${text}`);
102
121
  }
122
+ return res;
123
+ }
124
+
125
+ private async request(method: string, path: string, body?: unknown): Promise<any> {
126
+ const res = await this.requestRaw(method, path, body);
103
127
  return res.status === 204 ? undefined : res.json();
104
128
  }
105
129
 
@@ -135,6 +159,60 @@ export class GitHubClient implements GitHostProvider {
135
159
  async addLabel(prNumber: number, label: string): Promise<void> {
136
160
  await this.request('POST', `/issues/${prNumber}/labels`, { labels: [label] });
137
161
  }
162
+
163
+ /**
164
+ * The marker-bearing comment on the PR, `'absent'` when every page was searched, or `'unknown'`
165
+ * when the defensive page cap cut the search short. GitHub defaults `per_page` to 30 and orders
166
+ * ascending, and the maintained comment is posted once, early in a PR's life — so it lives in the
167
+ * first pages and the search returns as soon as it is seen. `'unknown'` is distinct from `'absent'`
168
+ * because running out of budget must not read as "not there": the caller would post a duplicate
169
+ * report on every later push.
170
+ */
171
+ private async findMarkedComment(prNumber: number, marker: string): Promise<{ id: number } | 'absent' | 'unknown'> {
172
+ let next: string | undefined = `/issues/${prNumber}/comments?per_page=100`;
173
+ // Defensive cap: 20 pages (2,000 comments) is far past any real PR; without it a malformed or
174
+ // adversarial `Link` header could loop this call forever.
175
+ for (let page = 0; next && page < 20; page += 1) {
176
+ const res: Response = await this.requestRaw('GET', next);
177
+ const body = (await res.json()) as any[];
178
+ const match = body.find((c: any) => (c.body ?? '').includes(marker));
179
+ if (match) return { id: match.id };
180
+ next = nextPageUrl(res.headers.get('link'));
181
+ }
182
+ return next ? 'unknown' : 'absent';
183
+ }
184
+
185
+ /**
186
+ * Find the comment carrying `marker` (an HTML comment embedded in the body) and replace its body,
187
+ * or post `body` as a new comment when none exists and `options.createIfAbsent` is not false.
188
+ * GitHub's comment PATCH endpoint is `/issues/comments/{id}`, not `/issues/{pr}/comments/{id}` —
189
+ * a comment id is unique per repository, not scoped under the issue/PR that carries it.
190
+ *
191
+ * Named to match `GitHostProvider.upsertComment` exactly (not e.g. `upsertIssueComment`): a
192
+ * `GitHubClient` registered directly as a provider must satisfy the interface's optional method
193
+ * under its real name, or callers that feature-test via `gitHost.upsertComment` would silently
194
+ * treat a fully-capable client as unsupported.
195
+ */
196
+ async upsertComment(
197
+ prNumber: number,
198
+ marker: string,
199
+ body: string,
200
+ options: { createIfAbsent?: boolean } = {}
201
+ ): Promise<void> {
202
+ const existing = await this.findMarkedComment(prNumber, marker);
203
+ if (existing === 'unknown') {
204
+ // Surfaces through the caller's warn-and-skip path; posting blind would duplicate the report.
205
+ throw new Error(
206
+ `PR #${prNumber} has too many comments to establish whether the "${marker}" comment already exists`
207
+ );
208
+ }
209
+ if (existing !== 'absent') {
210
+ await this.request('PATCH', `/issues/comments/${existing.id}`, { body });
211
+ return;
212
+ }
213
+ if (options.createIfAbsent === false) return;
214
+ await this.comment(prNumber, body);
215
+ }
138
216
  }
139
217
 
140
218
  /**
@@ -191,6 +269,15 @@ export class GitHubHostProvider implements GitHostProvider {
191
269
  return this.requireClient().addLabel(prNumber, label);
192
270
  }
193
271
 
272
+ async upsertComment(
273
+ prNumber: number,
274
+ marker: string,
275
+ body: string,
276
+ options?: { createIfAbsent?: boolean }
277
+ ): Promise<void> {
278
+ return this.requireClient().upsertComment(prNumber, marker, body, options);
279
+ }
280
+
194
281
  private resolveClient(remoteUrl?: string): GitHubClient | undefined {
195
282
  if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning);
196
283
  return this.client;