@teambit/ci 1.0.507 → 1.0.512
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.
- package/dist/ci.main.runtime.d.ts +3 -2
- package/dist/ci.main.runtime.js +5 -6
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/{preview-1787147173446.js → preview-1787168213927.js} +1 -1
- package/dist/sync/git-host-provider.d.ts +9 -0
- package/dist/sync/git-host-provider.js.map +1 -1
- package/dist/sync/github-client.d.ts +28 -0
- package/dist/sync/github-client.js +81 -3
- package/dist/sync/github-client.js.map +1 -1
- package/dist/sync/github-client.spec.js +249 -0
- package/dist/sync/github-client.spec.js.map +1 -1
- package/dist/sync/lane-sync-executor.d.ts +47 -3
- package/dist/sync/lane-sync-executor.js +191 -39
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +336 -0
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/sync-planner.d.ts +12 -2
- package/dist/sync/sync-planner.js +17 -8
- package/dist/sync/sync-planner.js.map +1 -1
- package/dist/sync/sync-planner.spec.js +64 -0
- package/dist/sync/sync-planner.spec.js.map +1 -1
- package/dist/sync/sync-state.d.ts +34 -3
- package/dist/sync/sync-state.js +61 -3
- package/dist/sync/sync-state.js.map +1 -1
- package/dist/sync/sync-state.spec.js +54 -0
- package/dist/sync/sync-state.spec.js.map +1 -1
- package/package.json +15 -15
- package/sync/git-host-provider.ts +8 -0
- package/sync/github-client.spec.ts +160 -0
- package/sync/github-client.ts +90 -3
- package/sync/lane-sync-executor.spec.ts +291 -0
- package/sync/lane-sync-executor.ts +233 -50
- package/sync/sync-planner.spec.ts +41 -0
- package/sync/sync-planner.ts +31 -10
- package/sync/sync-state.spec.ts +48 -0
- 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.
|
|
3
|
+
"version": "1.0.512",
|
|
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.
|
|
9
|
+
"version": "1.0.512"
|
|
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.
|
|
32
|
-
"@teambit/logger": "0.0.
|
|
33
|
-
"@teambit/workspace": "1.0.
|
|
34
|
-
"@teambit/builder": "1.0.
|
|
35
|
-
"@teambit/checkout": "1.0.
|
|
36
|
-
"@teambit/config-merger": "0.0.
|
|
37
|
-
"@teambit/dependency-resolver": "1.0.
|
|
38
|
-
"@teambit/export": "1.0.
|
|
39
|
-
"@teambit/importer": "1.0.
|
|
40
|
-
"@teambit/lanes": "1.0.
|
|
41
|
-
"@teambit/objects": "0.0.
|
|
42
|
-
"@teambit/snapping": "1.0.
|
|
43
|
-
"@teambit/status": "1.0.
|
|
31
|
+
"@teambit/cli": "0.0.1375",
|
|
32
|
+
"@teambit/logger": "0.0.1468",
|
|
33
|
+
"@teambit/workspace": "1.0.1117",
|
|
34
|
+
"@teambit/builder": "1.0.1117",
|
|
35
|
+
"@teambit/checkout": "1.0.1118",
|
|
36
|
+
"@teambit/config-merger": "0.0.984",
|
|
37
|
+
"@teambit/dependency-resolver": "1.0.1117",
|
|
38
|
+
"@teambit/export": "1.0.1117",
|
|
39
|
+
"@teambit/importer": "1.0.1117",
|
|
40
|
+
"@teambit/lanes": "1.0.1138",
|
|
41
|
+
"@teambit/objects": "0.0.624",
|
|
42
|
+
"@teambit/snapping": "1.0.1117",
|
|
43
|
+
"@teambit/status": "1.0.1142"
|
|
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
|
});
|
package/sync/github-client.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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} ${
|
|
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;
|