@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
;
|
|
2
|
-
import * as overview_0 from '/home/circleci/Library/Caches/Bit/capsules/8891be5ad/teambit.git_ci@1.0.
|
|
2
|
+
import * as overview_0 from '/home/circleci/Library/Caches/Bit/capsules/8891be5ad/teambit.git_ci@1.0.512/dist/ci.docs.mdx';
|
|
3
3
|
|
|
4
4
|
export const compositions = [];
|
|
5
5
|
export const overview = [overview_0];
|
|
@@ -30,6 +30,15 @@ export interface GitHostProvider {
|
|
|
30
30
|
closePr(prNumber: number, comment?: string): Promise<void>;
|
|
31
31
|
comment(prNumber: number, body: string): Promise<void>;
|
|
32
32
|
addLabel(prNumber: number, label: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Update the comment containing `marker` in place, or post a new one when none exists yet
|
|
35
|
+
* (`options.createIfAbsent` defaults to true). Optional: a provider that doesn't implement this
|
|
36
|
+
* is treated as not supporting the run-summary surface — callers skip rather than fall back to a
|
|
37
|
+
* plain, never-updated `comment`, which would leave one stale copy per push.
|
|
38
|
+
*/
|
|
39
|
+
upsertComment?(prNumber: number, marker: string, body: string, options?: {
|
|
40
|
+
createIfAbsent?: boolean;
|
|
41
|
+
}): Promise<void>;
|
|
33
42
|
}
|
|
34
43
|
/** The git host to use this run, or nothing plus the reason PR operations are being skipped. */
|
|
35
44
|
export type GitHostSelection = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["selectGitHostProvider","providers","remoteUrl","length","reason","claimants","filter","provider","matchesRemote","configured","find","isConfigured","listNames","map","name","join"],"sources":["git-host-provider.ts"],"sourcesContent":["/**\n * The git-host (pull request) contract `bit ci sync` is written against; nothing in the engine may\n * reach past it. Other hosts register via `ci.registerGitHostProvider(...)`. Adding a method is a\n * breaking change for out-of-tree providers.\n */\nexport type PrInfo = {\n number: number;\n state: 'open' | 'closed';\n labels: string[];\n headRef: string;\n htmlUrl: string;\n};\n\nexport interface GitHostProvider {\n /** Stable identifier, used in logs and to tell registered providers apart. E.g. `'github'`. */\n name: string;\n\n /** Does this provider serve the repository at `remoteUrl`? A host test, not a credentials test. */\n matchesRemote(remoteUrl: string): boolean;\n\n /**\n * Token and repository resolvable? Must not throw (unconfigured is a normal state — the engine\n * degrades to git-only sync), must be cheap and idempotent, and must never perform network I/O.\n */\n isConfigured(remoteUrl?: string): boolean;\n\n findPrByBranch(branch: string): Promise<PrInfo | undefined>;\n\n createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo>;\n\n closePr(prNumber: number, comment?: string): Promise<void>;\n\n comment(prNumber: number, body: string): Promise<void>;\n\n addLabel(prNumber: number, label: string): Promise<void>;\n}\n\n/** The git host to use this run, or nothing plus the reason PR operations are being skipped. */\nexport type GitHostSelection = {\n /** The host to use. undefined => PR-less (git-only) sync. */\n provider?: GitHostProvider;\n /** Set exactly when `provider` is undefined; surfaced once per run so PR-less is never silent. */\n reason?: string;\n};\n\n/**\n * Pick the provider that serves `remoteUrl`, or nothing (PR-less sync) plus the reason why.\n * A claim is exclusive: configuration never overrides `matchesRemote`, because routing to whichever\n * provider holds a token would act on the wrong host's PRs. A sole configured provider is the fallback\n * only when no provider claims the remote; two or more unclaimed-but-configured is too ambiguous.\n */\nexport function selectGitHostProvider(providers: GitHostProvider[], remoteUrl: string | undefined): GitHostSelection {\n if (!providers.length) {\n return { reason: 'no git host provider is registered — skipping all pull request operations' };\n }\n\n const claimants = remoteUrl ? providers.filter((provider) => provider.matchesRemote(remoteUrl)) : [];\n if (claimants.length) {\n const configured = claimants.find((provider) => provider.isConfigured(remoteUrl));\n if (configured) return { provider: configured };\n return {\n reason:\n `the ${listNames(claimants)} git host provider${claimants.length > 1 ? 's' : ''} serve${\n claimants.length > 1 ? '' : 's'\n } this repository's \"origin\" remote but ${claimants.length > 1 ? 'none is' : 'is not'} configured ` +\n `(missing credentials or repository) — skipping all pull request operations. No other provider is ` +\n `used in its place: that would act on the wrong host.`,\n };\n }\n\n const configured = providers.filter((provider) => provider.isConfigured(remoteUrl));\n if (configured.length === 1) return { provider: configured[0] };\n if (configured.length > 1) {\n return {\n reason:\n `no git host provider claims this repository's \"origin\" remote, and ${configured.length} are ` +\n `configured (${listNames(configured)}) — too ambiguous to pick one, skipping all pull request operations`,\n };\n }\n return {\n reason:\n `no git host provider is configured${remoteUrl ? '' : ' (and the \"origin\" remote could not be read)'} — ` +\n `skipping all pull request operations`,\n };\n}\n\nfunction listNames(providers: GitHostProvider[]): string {\n return providers.map((provider) => `\"${provider.name}\"`).join(', ');\n}\n"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;AACA;;
|
|
1
|
+
{"version":3,"names":["selectGitHostProvider","providers","remoteUrl","length","reason","claimants","filter","provider","matchesRemote","configured","find","isConfigured","listNames","map","name","join"],"sources":["git-host-provider.ts"],"sourcesContent":["/**\n * The git-host (pull request) contract `bit ci sync` is written against; nothing in the engine may\n * reach past it. Other hosts register via `ci.registerGitHostProvider(...)`. Adding a method is a\n * breaking change for out-of-tree providers.\n */\nexport type PrInfo = {\n number: number;\n state: 'open' | 'closed';\n labels: string[];\n headRef: string;\n htmlUrl: string;\n};\n\nexport interface GitHostProvider {\n /** Stable identifier, used in logs and to tell registered providers apart. E.g. `'github'`. */\n name: string;\n\n /** Does this provider serve the repository at `remoteUrl`? A host test, not a credentials test. */\n matchesRemote(remoteUrl: string): boolean;\n\n /**\n * Token and repository resolvable? Must not throw (unconfigured is a normal state — the engine\n * degrades to git-only sync), must be cheap and idempotent, and must never perform network I/O.\n */\n isConfigured(remoteUrl?: string): boolean;\n\n findPrByBranch(branch: string): Promise<PrInfo | undefined>;\n\n createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo>;\n\n closePr(prNumber: number, comment?: string): Promise<void>;\n\n comment(prNumber: number, body: string): Promise<void>;\n\n addLabel(prNumber: number, label: string): Promise<void>;\n\n /**\n * Update the comment containing `marker` in place, or post a new one when none exists yet\n * (`options.createIfAbsent` defaults to true). Optional: a provider that doesn't implement this\n * is treated as not supporting the run-summary surface — callers skip rather than fall back to a\n * plain, never-updated `comment`, which would leave one stale copy per push.\n */\n upsertComment?(prNumber: number, marker: string, body: string, options?: { createIfAbsent?: boolean }): Promise<void>;\n}\n\n/** The git host to use this run, or nothing plus the reason PR operations are being skipped. */\nexport type GitHostSelection = {\n /** The host to use. undefined => PR-less (git-only) sync. */\n provider?: GitHostProvider;\n /** Set exactly when `provider` is undefined; surfaced once per run so PR-less is never silent. */\n reason?: string;\n};\n\n/**\n * Pick the provider that serves `remoteUrl`, or nothing (PR-less sync) plus the reason why.\n * A claim is exclusive: configuration never overrides `matchesRemote`, because routing to whichever\n * provider holds a token would act on the wrong host's PRs. A sole configured provider is the fallback\n * only when no provider claims the remote; two or more unclaimed-but-configured is too ambiguous.\n */\nexport function selectGitHostProvider(providers: GitHostProvider[], remoteUrl: string | undefined): GitHostSelection {\n if (!providers.length) {\n return { reason: 'no git host provider is registered — skipping all pull request operations' };\n }\n\n const claimants = remoteUrl ? providers.filter((provider) => provider.matchesRemote(remoteUrl)) : [];\n if (claimants.length) {\n const configured = claimants.find((provider) => provider.isConfigured(remoteUrl));\n if (configured) return { provider: configured };\n return {\n reason:\n `the ${listNames(claimants)} git host provider${claimants.length > 1 ? 's' : ''} serve${\n claimants.length > 1 ? '' : 's'\n } this repository's \"origin\" remote but ${claimants.length > 1 ? 'none is' : 'is not'} configured ` +\n `(missing credentials or repository) — skipping all pull request operations. No other provider is ` +\n `used in its place: that would act on the wrong host.`,\n };\n }\n\n const configured = providers.filter((provider) => provider.isConfigured(remoteUrl));\n if (configured.length === 1) return { provider: configured[0] };\n if (configured.length > 1) {\n return {\n reason:\n `no git host provider claims this repository's \"origin\" remote, and ${configured.length} are ` +\n `configured (${listNames(configured)}) — too ambiguous to pick one, skipping all pull request operations`,\n };\n }\n return {\n reason:\n `no git host provider is configured${remoteUrl ? '' : ' (and the \"origin\" remote could not be read)'} — ` +\n `skipping all pull request operations`,\n };\n}\n\nfunction listNames(providers: GitHostProvider[]): string {\n return providers.map((provider) => `\"${provider.name}\"`).join(', ');\n}\n"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;AACA;;AAyCA;;AAQA;AACA;AACA;AACA;AACA;AACA;AACO,SAASA,qBAAqBA,CAACC,SAA4B,EAAEC,SAA6B,EAAoB;EACnH,IAAI,CAACD,SAAS,CAACE,MAAM,EAAE;IACrB,OAAO;MAAEC,MAAM,EAAE;IAA4E,CAAC;EAChG;EAEA,MAAMC,SAAS,GAAGH,SAAS,GAAGD,SAAS,CAACK,MAAM,CAAEC,QAAQ,IAAKA,QAAQ,CAACC,aAAa,CAACN,SAAS,CAAC,CAAC,GAAG,EAAE;EACpG,IAAIG,SAAS,CAACF,MAAM,EAAE;IACpB,MAAMM,UAAU,GAAGJ,SAAS,CAACK,IAAI,CAAEH,QAAQ,IAAKA,QAAQ,CAACI,YAAY,CAACT,SAAS,CAAC,CAAC;IACjF,IAAIO,UAAU,EAAE,OAAO;MAAEF,QAAQ,EAAEE;IAAW,CAAC;IAC/C,OAAO;MACLL,MAAM,EACJ,OAAOQ,SAAS,CAACP,SAAS,CAAC,qBAAqBA,SAAS,CAACF,MAAM,GAAG,CAAC,GAAG,GAAG,GAAG,EAAE,SAC7EE,SAAS,CAACF,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,0CACSE,SAAS,CAACF,MAAM,GAAG,CAAC,GAAG,SAAS,GAAG,QAAQ,cAAc,GACnG,mGAAmG,GACnG;IACJ,CAAC;EACH;EAEA,MAAMM,UAAU,GAAGR,SAAS,CAACK,MAAM,CAAEC,QAAQ,IAAKA,QAAQ,CAACI,YAAY,CAACT,SAAS,CAAC,CAAC;EACnF,IAAIO,UAAU,CAACN,MAAM,KAAK,CAAC,EAAE,OAAO;IAAEI,QAAQ,EAAEE,UAAU,CAAC,CAAC;EAAE,CAAC;EAC/D,IAAIA,UAAU,CAACN,MAAM,GAAG,CAAC,EAAE;IACzB,OAAO;MACLC,MAAM,EACJ,sEAAsEK,UAAU,CAACN,MAAM,OAAO,GAC9F,eAAeS,SAAS,CAACH,UAAU,CAAC;IACxC,CAAC;EACH;EACA,OAAO;IACLL,MAAM,EACJ,qCAAqCF,SAAS,GAAG,EAAE,GAAG,8CAA8C,KAAK,GACzG;EACJ,CAAC;AACH;AAEA,SAASU,SAASA,CAACX,SAA4B,EAAU;EACvD,OAAOA,SAAS,CAACY,GAAG,CAAEN,QAAQ,IAAK,IAAIA,QAAQ,CAACO,IAAI,GAAG,CAAC,CAACC,IAAI,CAAC,IAAI,CAAC;AACrE","ignoreList":[]}
|
|
@@ -32,6 +32,8 @@ export declare class GitHubClient implements GitHostProvider {
|
|
|
32
32
|
matchesRemote(remoteUrl: string): boolean;
|
|
33
33
|
/** A constructed client always has both halves — it cannot be built without them. */
|
|
34
34
|
isConfigured(): boolean;
|
|
35
|
+
/** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */
|
|
36
|
+
private requestRaw;
|
|
35
37
|
private request;
|
|
36
38
|
private toPrInfo;
|
|
37
39
|
findPrByBranch(branch: string): Promise<PrInfo | undefined>;
|
|
@@ -44,6 +46,29 @@ export declare class GitHubClient implements GitHostProvider {
|
|
|
44
46
|
closePr(prNumber: number, comment?: string): Promise<void>;
|
|
45
47
|
comment(prNumber: number, body: string): Promise<void>;
|
|
46
48
|
addLabel(prNumber: number, label: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* The marker-bearing comment on the PR, `'absent'` when every page was searched, or `'unknown'`
|
|
51
|
+
* when the defensive page cap cut the search short. GitHub defaults `per_page` to 30 and orders
|
|
52
|
+
* ascending, and the maintained comment is posted once, early in a PR's life — so it lives in the
|
|
53
|
+
* first pages and the search returns as soon as it is seen. `'unknown'` is distinct from `'absent'`
|
|
54
|
+
* because running out of budget must not read as "not there": the caller would post a duplicate
|
|
55
|
+
* report on every later push.
|
|
56
|
+
*/
|
|
57
|
+
private findMarkedComment;
|
|
58
|
+
/**
|
|
59
|
+
* Find the comment carrying `marker` (an HTML comment embedded in the body) and replace its body,
|
|
60
|
+
* or post `body` as a new comment when none exists and `options.createIfAbsent` is not false.
|
|
61
|
+
* GitHub's comment PATCH endpoint is `/issues/comments/{id}`, not `/issues/{pr}/comments/{id}` —
|
|
62
|
+
* a comment id is unique per repository, not scoped under the issue/PR that carries it.
|
|
63
|
+
*
|
|
64
|
+
* Named to match `GitHostProvider.upsertComment` exactly (not e.g. `upsertIssueComment`): a
|
|
65
|
+
* `GitHubClient` registered directly as a provider must satisfy the interface's optional method
|
|
66
|
+
* under its real name, or callers that feature-test via `gitHost.upsertComment` would silently
|
|
67
|
+
* treat a fully-capable client as unsupported.
|
|
68
|
+
*/
|
|
69
|
+
upsertComment(prNumber: number, marker: string, body: string, options?: {
|
|
70
|
+
createIfAbsent?: boolean;
|
|
71
|
+
}): Promise<void>;
|
|
47
72
|
}
|
|
48
73
|
/**
|
|
49
74
|
* The registrable GitHub provider: resolves its `GitHubClient` from the environment lazily, since
|
|
@@ -74,6 +99,9 @@ export declare class GitHubHostProvider implements GitHostProvider {
|
|
|
74
99
|
closePr(prNumber: number, comment?: string): Promise<void>;
|
|
75
100
|
comment(prNumber: number, body: string): Promise<void>;
|
|
76
101
|
addLabel(prNumber: number, label: string): Promise<void>;
|
|
102
|
+
upsertComment(prNumber: number, marker: string, body: string, options?: {
|
|
103
|
+
createIfAbsent?: boolean;
|
|
104
|
+
}): Promise<void>;
|
|
77
105
|
private resolveClient;
|
|
78
106
|
private requireClient;
|
|
79
107
|
}
|
|
@@ -35,6 +35,23 @@ function isGitHubRemote(remoteUrl) {
|
|
|
35
35
|
}
|
|
36
36
|
const API = 'https://api.github.com';
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The `rel="next"` URL out of a GitHub `Link` response header, or undefined on the last page. The
|
|
40
|
+
* header is response data, and `requestRaw` attaches the bearer token to whatever URL it is handed —
|
|
41
|
+
* so a next link whose origin is not the API's reads as "last page", never as a request target.
|
|
42
|
+
*/
|
|
43
|
+
function nextPageUrl(linkHeader) {
|
|
44
|
+
if (!linkHeader) return undefined;
|
|
45
|
+
const next = linkHeader.split(',').find(part => part.includes('rel="next"'));
|
|
46
|
+
const url = next?.match(/<([^>]+)>/)?.[1];
|
|
47
|
+
if (!url) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
return new URL(url).origin === API ? url : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
/** Warning sink; a plain callback rather than a `Logger` so this module needs no logger aspect. */
|
|
39
56
|
|
|
40
57
|
const noopWarn = () => {};
|
|
@@ -82,8 +99,11 @@ class GitHubClient {
|
|
|
82
99
|
isConfigured() {
|
|
83
100
|
return Boolean(this.token && this.repo);
|
|
84
101
|
}
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
|
|
103
|
+
/** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */
|
|
104
|
+
async requestRaw(method, pathOrUrl, body) {
|
|
105
|
+
const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`;
|
|
106
|
+
const res = await this.fetchImpl(url, {
|
|
87
107
|
method,
|
|
88
108
|
headers: {
|
|
89
109
|
authorization: `Bearer ${this.token}`,
|
|
@@ -95,8 +115,12 @@ class GitHubClient {
|
|
|
95
115
|
});
|
|
96
116
|
if (!res.ok) {
|
|
97
117
|
const text = await res.text().catch(() => '');
|
|
98
|
-
throw new Error(`GitHub API ${method} ${
|
|
118
|
+
throw new Error(`GitHub API ${method} ${pathOrUrl} failed: ${res.status} ${text}`);
|
|
99
119
|
}
|
|
120
|
+
return res;
|
|
121
|
+
}
|
|
122
|
+
async request(method, path, body) {
|
|
123
|
+
const res = await this.requestRaw(method, path, body);
|
|
100
124
|
return res.status === 204 ? undefined : res.json();
|
|
101
125
|
}
|
|
102
126
|
toPrInfo(raw) {
|
|
@@ -132,6 +156,57 @@ class GitHubClient {
|
|
|
132
156
|
labels: [label]
|
|
133
157
|
});
|
|
134
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The marker-bearing comment on the PR, `'absent'` when every page was searched, or `'unknown'`
|
|
162
|
+
* when the defensive page cap cut the search short. GitHub defaults `per_page` to 30 and orders
|
|
163
|
+
* ascending, and the maintained comment is posted once, early in a PR's life — so it lives in the
|
|
164
|
+
* first pages and the search returns as soon as it is seen. `'unknown'` is distinct from `'absent'`
|
|
165
|
+
* because running out of budget must not read as "not there": the caller would post a duplicate
|
|
166
|
+
* report on every later push.
|
|
167
|
+
*/
|
|
168
|
+
async findMarkedComment(prNumber, marker) {
|
|
169
|
+
let next = `/issues/${prNumber}/comments?per_page=100`;
|
|
170
|
+
// Defensive cap: 20 pages (2,000 comments) is far past any real PR; without it a malformed or
|
|
171
|
+
// adversarial `Link` header could loop this call forever.
|
|
172
|
+
for (let page = 0; next && page < 20; page += 1) {
|
|
173
|
+
const res = await this.requestRaw('GET', next);
|
|
174
|
+
const body = await res.json();
|
|
175
|
+
const match = body.find(c => (c.body ?? '').includes(marker));
|
|
176
|
+
if (match) return {
|
|
177
|
+
id: match.id
|
|
178
|
+
};
|
|
179
|
+
next = nextPageUrl(res.headers.get('link'));
|
|
180
|
+
}
|
|
181
|
+
return next ? 'unknown' : 'absent';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find the comment carrying `marker` (an HTML comment embedded in the body) and replace its body,
|
|
186
|
+
* or post `body` as a new comment when none exists and `options.createIfAbsent` is not false.
|
|
187
|
+
* GitHub's comment PATCH endpoint is `/issues/comments/{id}`, not `/issues/{pr}/comments/{id}` —
|
|
188
|
+
* a comment id is unique per repository, not scoped under the issue/PR that carries it.
|
|
189
|
+
*
|
|
190
|
+
* Named to match `GitHostProvider.upsertComment` exactly (not e.g. `upsertIssueComment`): a
|
|
191
|
+
* `GitHubClient` registered directly as a provider must satisfy the interface's optional method
|
|
192
|
+
* under its real name, or callers that feature-test via `gitHost.upsertComment` would silently
|
|
193
|
+
* treat a fully-capable client as unsupported.
|
|
194
|
+
*/
|
|
195
|
+
async upsertComment(prNumber, marker, body, options = {}) {
|
|
196
|
+
const existing = await this.findMarkedComment(prNumber, marker);
|
|
197
|
+
if (existing === 'unknown') {
|
|
198
|
+
// Surfaces through the caller's warn-and-skip path; posting blind would duplicate the report.
|
|
199
|
+
throw new Error(`PR #${prNumber} has too many comments to establish whether the "${marker}" comment already exists`);
|
|
200
|
+
}
|
|
201
|
+
if (existing !== 'absent') {
|
|
202
|
+
await this.request('PATCH', `/issues/comments/${existing.id}`, {
|
|
203
|
+
body
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (options.createIfAbsent === false) return;
|
|
208
|
+
await this.comment(prNumber, body);
|
|
209
|
+
}
|
|
135
210
|
}
|
|
136
211
|
|
|
137
212
|
/**
|
|
@@ -181,6 +256,9 @@ class GitHubHostProvider {
|
|
|
181
256
|
async addLabel(prNumber, label) {
|
|
182
257
|
return this.requireClient().addLabel(prNumber, label);
|
|
183
258
|
}
|
|
259
|
+
async upsertComment(prNumber, marker, body, options) {
|
|
260
|
+
return this.requireClient().upsertComment(prNumber, marker, body, options);
|
|
261
|
+
}
|
|
184
262
|
resolveClient(remoteUrl) {
|
|
185
263
|
if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning);
|
|
186
264
|
return this.client;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["parseGitHubRepo","remoteUrl","url","trim","hasScheme","test","match","remoteHost","toLowerCase","isGitHubRemote","API","noopWarn","GitHubClient","constructor","opts","_defineProperty","token","repo","fetchImpl","fetch","fromEnv","gitRemoteUrl","warn","process","env","BIT_GITHUB_TOKEN","GITHUB_TOKEN","undefined","repoFromRemote","repoFromEnv","GITHUB_REPOSITORY","matchesRemote","isConfigured","Boolean","request","method","path","body","res","headers","authorization","accept","JSON","stringify","ok","text","catch","Error","status","json","toPrInfo","raw","number","state","labels","map","l","name","headRef","head","ref","htmlUrl","html_url","findPrByBranch","branch","owner","split","list","encodeURIComponent","length","createPr","closePr","prNumber","comment","addLabel","label","exports","GitHubHostProvider","onWarning","matches","remoteHint","resolveClient","requireClient","client"],"sources":["github-client.ts"],"sourcesContent":["import type { GitHostProvider, PrInfo } from './git-host-provider';\n\n/**\n * The `owner/repo` out of a github.com remote URL, anchored to the URL authority. `:digits` is a port\n * only under a scheme; in the scp-like form (`git@github.com:12345/repo`) the colon starts the PATH\n * and an all-digits owner is a legal GitHub username.\n */\nexport function parseGitHubRepo(remoteUrl: string): string | undefined {\n const url = remoteUrl.trim();\n const hasScheme = /^[a-z+]+:\\/\\//i.test(url);\n const match = hasScheme\n ? url.match(/(?:\\/\\/|@)github\\.com(?::\\d+)?\\/([^/]+\\/[^/]+?)(?:\\.git)?$/i)\n : url.match(/(?:^|@)github\\.com[:/]([^/]+\\/[^/]+?)(?:\\.git)?$/i);\n return match?.[1];\n}\n\n/** The host a git remote URL points at, lower-cased, or undefined when the URL has no authority. */\nfunction remoteHost(remoteUrl: string): string | undefined {\n return remoteUrl\n .trim()\n .match(/^(?:[a-z+]+:\\/\\/)?(?:[^@/]+@)?([^:/]+)/i)?.[1]\n ?.toLowerCase();\n}\n\n/**\n * Does this remote point at GitHub? A host test only — an unparseable github.com remote is still\n * GitHub's to claim. Host equality, not substring: `github.com` can be a path segment on another host.\n */\nexport function isGitHubRemote(remoteUrl: string): boolean {\n return remoteHost(remoteUrl) === 'github.com';\n}\n\nconst API = 'https://api.github.com';\n\n/** Warning sink; a plain callback rather than a `Logger` so this module needs no logger aspect. */\nexport type WarnFn = (message: string) => void;\n\nconst noopWarn: WarnFn = () => {};\n\nexport class GitHubClient implements GitHostProvider {\n readonly name = 'github';\n\n private token: string;\n /** `owner/repo`. Readable so callers (and tests) can see *which* repository a resolved client speaks to. */\n readonly repo: string;\n private fetchImpl: typeof fetch;\n\n constructor(opts: { token: string; repo: string; fetchImpl?: typeof fetch }) {\n this.token = opts.token;\n this.repo = opts.repo;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n /**\n * The client this environment describes, or undefined when it describes none. `origin` outranks\n * `GITHUB_REPOSITORY`: every PR operation names a branch that only exists on `origin`, so if the two\n * disagree the origin-parsed repository wins and the mismatch is warned about once.\n */\n static fromEnv(gitRemoteUrl?: string, warn: WarnFn = noopWarn): GitHubClient | undefined {\n // BIT_GITHUB_TOKEN is the override; GITHUB_TOKEN is the ambient default Actions injects everywhere.\n const token = process.env.BIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN;\n // A remote that is demonstrably not GitHub's is a hard no, whatever the environment says —\n // GITHUB_REPOSITORY is set on every Actions job, including ones whose origin is another host.\n if (gitRemoteUrl && !isGitHubRemote(gitRemoteUrl)) return undefined;\n const repoFromRemote = gitRemoteUrl ? parseGitHubRepo(gitRemoteUrl) : undefined;\n const repoFromEnv = process.env.GITHUB_REPOSITORY;\n const repo = repoFromRemote || repoFromEnv;\n if (!token || !repo) return undefined;\n // GitHub owner/repo names are case-insensitive.\n if (repoFromRemote && repoFromEnv && repoFromRemote.toLowerCase() !== repoFromEnv.toLowerCase()) {\n warn(\n `bit ci sync: GITHUB_REPOSITORY is \"${repoFromEnv}\" but the \"origin\" remote points at ` +\n `\"${repoFromRemote}\" — using \"${repoFromRemote}\", the repository this run pushes its branches to.`\n );\n }\n return new GitHubClient({ token, repo });\n }\n\n matchesRemote(remoteUrl: string): boolean {\n return isGitHubRemote(remoteUrl);\n }\n\n /** A constructed client always has both halves — it cannot be built without them. */\n isConfigured(): boolean {\n return Boolean(this.token && this.repo);\n }\n\n private async request(method: string, path: string, body?: unknown): Promise<any> {\n const res = await this.fetchImpl(`${API}/repos/${this.repo}${path}`, {\n method,\n headers: {\n authorization: `Bearer ${this.token}`,\n accept: 'application/vnd.github+json',\n 'content-type': 'application/json',\n 'x-github-api-version': '2022-11-28',\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`GitHub API ${method} ${path} failed: ${res.status} ${text}`);\n }\n return res.status === 204 ? undefined : res.json();\n }\n\n private toPrInfo(raw: any): PrInfo {\n return {\n number: raw.number,\n state: raw.state,\n labels: (raw.labels ?? []).map((l: any) => (typeof l === 'string' ? l : l.name)),\n headRef: raw.head?.ref,\n htmlUrl: raw.html_url,\n };\n }\n\n async findPrByBranch(branch: string): Promise<PrInfo | undefined> {\n const owner = this.repo.split('/')[0];\n const list = await this.request('GET', `/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`);\n return list.length ? this.toPrInfo(list[0]) : undefined;\n }\n\n async createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo> {\n return this.toPrInfo(await this.request('POST', '/pulls', opts));\n }\n\n async closePr(prNumber: number, comment?: string): Promise<void> {\n if (comment) await this.comment(prNumber, comment);\n await this.request('PATCH', `/pulls/${prNumber}`, { state: 'closed' });\n }\n\n async comment(prNumber: number, body: string): Promise<void> {\n await this.request('POST', `/issues/${prNumber}/comments`, { body });\n }\n\n async addLabel(prNumber: number, label: string): Promise<void> {\n await this.request('POST', `/issues/${prNumber}/labels`, { labels: [label] });\n }\n}\n\n/**\n * The registrable GitHub provider: resolves its `GitHubClient` from the environment lazily, since\n * registration happens at aspect load, before credentials are known. A resolved client is memoized;\n * an unresolved one is retried so a later call that does carry the remote URL can still succeed.\n */\nexport class GitHubHostProvider implements GitHostProvider {\n readonly name = 'github';\n\n private client: GitHubClient | undefined;\n\n /** The last `origin` URL seen, so a PR method can still recover `owner/repo` from it. */\n private remoteHint: string | undefined;\n\n constructor(private onWarning: WarnFn = noopWarn) {}\n\n matchesRemote(remoteUrl: string): boolean {\n const matches = isGitHubRemote(remoteUrl);\n // Only remember a remote that is ours — a foreign hint could later pair with GITHUB_REPOSITORY\n // into a client for the wrong repository.\n if (matches) this.remoteHint = remoteUrl;\n return matches;\n }\n\n /**\n * A known non-GitHub remote can never count as configured, however complete the environment looks:\n * on Actions the env is always set, so answering \"configured\" for a remote this provider declined to\n * claim would make it the sole-configured fallback and aim PRs at the wrong host's repository.\n */\n isConfigured(remoteUrl?: string): boolean {\n if (remoteUrl && !isGitHubRemote(remoteUrl)) return false;\n if (remoteUrl) this.remoteHint = remoteUrl;\n return Boolean(this.resolveClient(remoteUrl));\n }\n\n async findPrByBranch(branch: string): Promise<PrInfo | undefined> {\n return this.requireClient().findPrByBranch(branch);\n }\n\n async createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo> {\n return this.requireClient().createPr(opts);\n }\n\n async closePr(prNumber: number, comment?: string): Promise<void> {\n return this.requireClient().closePr(prNumber, comment);\n }\n\n async comment(prNumber: number, body: string): Promise<void> {\n return this.requireClient().comment(prNumber, body);\n }\n\n async addLabel(prNumber: number, label: string): Promise<void> {\n return this.requireClient().addLabel(prNumber, label);\n }\n\n private resolveClient(remoteUrl?: string): GitHubClient | undefined {\n if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning);\n return this.client;\n }\n\n private requireClient(): GitHubClient {\n const client = this.resolveClient();\n if (!client) {\n throw new Error(\n `the github host provider is not configured: set BIT_GITHUB_TOKEN (or GITHUB_TOKEN), and either ` +\n `GITHUB_REPOSITORY or a github.com \"origin\" remote`\n );\n }\n return client;\n }\n}\n"],"mappings":"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","ignoreList":[]}
|
|
1
|
+
{"version":3,"names":["parseGitHubRepo","remoteUrl","url","trim","hasScheme","test","match","remoteHost","toLowerCase","isGitHubRemote","API","nextPageUrl","linkHeader","undefined","next","split","find","part","includes","URL","origin","noopWarn","GitHubClient","constructor","opts","_defineProperty","token","repo","fetchImpl","fetch","fromEnv","gitRemoteUrl","warn","process","env","BIT_GITHUB_TOKEN","GITHUB_TOKEN","repoFromRemote","repoFromEnv","GITHUB_REPOSITORY","matchesRemote","isConfigured","Boolean","requestRaw","method","pathOrUrl","body","res","headers","authorization","accept","JSON","stringify","ok","text","catch","Error","status","request","path","json","toPrInfo","raw","number","state","labels","map","l","name","headRef","head","ref","htmlUrl","html_url","findPrByBranch","branch","owner","list","encodeURIComponent","length","createPr","closePr","prNumber","comment","addLabel","label","findMarkedComment","marker","page","c","id","get","upsertComment","options","existing","createIfAbsent","exports","GitHubHostProvider","onWarning","matches","remoteHint","resolveClient","requireClient","client"],"sources":["github-client.ts"],"sourcesContent":["import type { GitHostProvider, PrInfo } from './git-host-provider';\n\n/**\n * The `owner/repo` out of a github.com remote URL, anchored to the URL authority. `:digits` is a port\n * only under a scheme; in the scp-like form (`git@github.com:12345/repo`) the colon starts the PATH\n * and an all-digits owner is a legal GitHub username.\n */\nexport function parseGitHubRepo(remoteUrl: string): string | undefined {\n const url = remoteUrl.trim();\n const hasScheme = /^[a-z+]+:\\/\\//i.test(url);\n const match = hasScheme\n ? url.match(/(?:\\/\\/|@)github\\.com(?::\\d+)?\\/([^/]+\\/[^/]+?)(?:\\.git)?$/i)\n : url.match(/(?:^|@)github\\.com[:/]([^/]+\\/[^/]+?)(?:\\.git)?$/i);\n return match?.[1];\n}\n\n/** The host a git remote URL points at, lower-cased, or undefined when the URL has no authority. */\nfunction remoteHost(remoteUrl: string): string | undefined {\n return remoteUrl\n .trim()\n .match(/^(?:[a-z+]+:\\/\\/)?(?:[^@/]+@)?([^:/]+)/i)?.[1]\n ?.toLowerCase();\n}\n\n/**\n * Does this remote point at GitHub? A host test only — an unparseable github.com remote is still\n * GitHub's to claim. Host equality, not substring: `github.com` can be a path segment on another host.\n */\nexport function isGitHubRemote(remoteUrl: string): boolean {\n return remoteHost(remoteUrl) === 'github.com';\n}\n\nconst API = 'https://api.github.com';\n\n/**\n * The `rel=\"next\"` URL out of a GitHub `Link` response header, or undefined on the last page. The\n * header is response data, and `requestRaw` attaches the bearer token to whatever URL it is handed —\n * so a next link whose origin is not the API's reads as \"last page\", never as a request target.\n */\nfunction nextPageUrl(linkHeader: string | null): string | undefined {\n if (!linkHeader) return undefined;\n const next = linkHeader.split(',').find((part) => part.includes('rel=\"next\"'));\n const url = next?.match(/<([^>]+)>/)?.[1];\n if (!url) return undefined;\n try {\n return new URL(url).origin === API ? url : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Warning sink; a plain callback rather than a `Logger` so this module needs no logger aspect. */\nexport type WarnFn = (message: string) => void;\n\nconst noopWarn: WarnFn = () => {};\n\nexport class GitHubClient implements GitHostProvider {\n readonly name = 'github';\n\n private token: string;\n /** `owner/repo`. Readable so callers (and tests) can see *which* repository a resolved client speaks to. */\n readonly repo: string;\n private fetchImpl: typeof fetch;\n\n constructor(opts: { token: string; repo: string; fetchImpl?: typeof fetch }) {\n this.token = opts.token;\n this.repo = opts.repo;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n /**\n * The client this environment describes, or undefined when it describes none. `origin` outranks\n * `GITHUB_REPOSITORY`: every PR operation names a branch that only exists on `origin`, so if the two\n * disagree the origin-parsed repository wins and the mismatch is warned about once.\n */\n static fromEnv(gitRemoteUrl?: string, warn: WarnFn = noopWarn): GitHubClient | undefined {\n // BIT_GITHUB_TOKEN is the override; GITHUB_TOKEN is the ambient default Actions injects everywhere.\n const token = process.env.BIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN;\n // A remote that is demonstrably not GitHub's is a hard no, whatever the environment says —\n // GITHUB_REPOSITORY is set on every Actions job, including ones whose origin is another host.\n if (gitRemoteUrl && !isGitHubRemote(gitRemoteUrl)) return undefined;\n const repoFromRemote = gitRemoteUrl ? parseGitHubRepo(gitRemoteUrl) : undefined;\n const repoFromEnv = process.env.GITHUB_REPOSITORY;\n const repo = repoFromRemote || repoFromEnv;\n if (!token || !repo) return undefined;\n // GitHub owner/repo names are case-insensitive.\n if (repoFromRemote && repoFromEnv && repoFromRemote.toLowerCase() !== repoFromEnv.toLowerCase()) {\n warn(\n `bit ci sync: GITHUB_REPOSITORY is \"${repoFromEnv}\" but the \"origin\" remote points at ` +\n `\"${repoFromRemote}\" — using \"${repoFromRemote}\", the repository this run pushes its branches to.`\n );\n }\n return new GitHubClient({ token, repo });\n }\n\n matchesRemote(remoteUrl: string): boolean {\n return isGitHubRemote(remoteUrl);\n }\n\n /** A constructed client always has both halves — it cannot be built without them. */\n isConfigured(): boolean {\n return Boolean(this.token && this.repo);\n }\n\n /** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */\n private async requestRaw(method: string, pathOrUrl: string, body?: unknown): Promise<Response> {\n const url = /^https?:\\/\\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`;\n const res = await this.fetchImpl(url, {\n method,\n headers: {\n authorization: `Bearer ${this.token}`,\n accept: 'application/vnd.github+json',\n 'content-type': 'application/json',\n 'x-github-api-version': '2022-11-28',\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) {\n const text = await res.text().catch(() => '');\n throw new Error(`GitHub API ${method} ${pathOrUrl} failed: ${res.status} ${text}`);\n }\n return res;\n }\n\n private async request(method: string, path: string, body?: unknown): Promise<any> {\n const res = await this.requestRaw(method, path, body);\n return res.status === 204 ? undefined : res.json();\n }\n\n private toPrInfo(raw: any): PrInfo {\n return {\n number: raw.number,\n state: raw.state,\n labels: (raw.labels ?? []).map((l: any) => (typeof l === 'string' ? l : l.name)),\n headRef: raw.head?.ref,\n htmlUrl: raw.html_url,\n };\n }\n\n async findPrByBranch(branch: string): Promise<PrInfo | undefined> {\n const owner = this.repo.split('/')[0];\n const list = await this.request('GET', `/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`);\n return list.length ? this.toPrInfo(list[0]) : undefined;\n }\n\n async createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo> {\n return this.toPrInfo(await this.request('POST', '/pulls', opts));\n }\n\n async closePr(prNumber: number, comment?: string): Promise<void> {\n if (comment) await this.comment(prNumber, comment);\n await this.request('PATCH', `/pulls/${prNumber}`, { state: 'closed' });\n }\n\n async comment(prNumber: number, body: string): Promise<void> {\n await this.request('POST', `/issues/${prNumber}/comments`, { body });\n }\n\n async addLabel(prNumber: number, label: string): Promise<void> {\n await this.request('POST', `/issues/${prNumber}/labels`, { labels: [label] });\n }\n\n /**\n * The marker-bearing comment on the PR, `'absent'` when every page was searched, or `'unknown'`\n * when the defensive page cap cut the search short. GitHub defaults `per_page` to 30 and orders\n * ascending, and the maintained comment is posted once, early in a PR's life — so it lives in the\n * first pages and the search returns as soon as it is seen. `'unknown'` is distinct from `'absent'`\n * because running out of budget must not read as \"not there\": the caller would post a duplicate\n * report on every later push.\n */\n private async findMarkedComment(prNumber: number, marker: string): Promise<{ id: number } | 'absent' | 'unknown'> {\n let next: string | undefined = `/issues/${prNumber}/comments?per_page=100`;\n // Defensive cap: 20 pages (2,000 comments) is far past any real PR; without it a malformed or\n // adversarial `Link` header could loop this call forever.\n for (let page = 0; next && page < 20; page += 1) {\n const res: Response = await this.requestRaw('GET', next);\n const body = (await res.json()) as any[];\n const match = body.find((c: any) => (c.body ?? '').includes(marker));\n if (match) return { id: match.id };\n next = nextPageUrl(res.headers.get('link'));\n }\n return next ? 'unknown' : 'absent';\n }\n\n /**\n * Find the comment carrying `marker` (an HTML comment embedded in the body) and replace its body,\n * or post `body` as a new comment when none exists and `options.createIfAbsent` is not false.\n * GitHub's comment PATCH endpoint is `/issues/comments/{id}`, not `/issues/{pr}/comments/{id}` —\n * a comment id is unique per repository, not scoped under the issue/PR that carries it.\n *\n * Named to match `GitHostProvider.upsertComment` exactly (not e.g. `upsertIssueComment`): a\n * `GitHubClient` registered directly as a provider must satisfy the interface's optional method\n * under its real name, or callers that feature-test via `gitHost.upsertComment` would silently\n * treat a fully-capable client as unsupported.\n */\n async upsertComment(\n prNumber: number,\n marker: string,\n body: string,\n options: { createIfAbsent?: boolean } = {}\n ): Promise<void> {\n const existing = await this.findMarkedComment(prNumber, marker);\n if (existing === 'unknown') {\n // Surfaces through the caller's warn-and-skip path; posting blind would duplicate the report.\n throw new Error(\n `PR #${prNumber} has too many comments to establish whether the \"${marker}\" comment already exists`\n );\n }\n if (existing !== 'absent') {\n await this.request('PATCH', `/issues/comments/${existing.id}`, { body });\n return;\n }\n if (options.createIfAbsent === false) return;\n await this.comment(prNumber, body);\n }\n}\n\n/**\n * The registrable GitHub provider: resolves its `GitHubClient` from the environment lazily, since\n * registration happens at aspect load, before credentials are known. A resolved client is memoized;\n * an unresolved one is retried so a later call that does carry the remote URL can still succeed.\n */\nexport class GitHubHostProvider implements GitHostProvider {\n readonly name = 'github';\n\n private client: GitHubClient | undefined;\n\n /** The last `origin` URL seen, so a PR method can still recover `owner/repo` from it. */\n private remoteHint: string | undefined;\n\n constructor(private onWarning: WarnFn = noopWarn) {}\n\n matchesRemote(remoteUrl: string): boolean {\n const matches = isGitHubRemote(remoteUrl);\n // Only remember a remote that is ours — a foreign hint could later pair with GITHUB_REPOSITORY\n // into a client for the wrong repository.\n if (matches) this.remoteHint = remoteUrl;\n return matches;\n }\n\n /**\n * A known non-GitHub remote can never count as configured, however complete the environment looks:\n * on Actions the env is always set, so answering \"configured\" for a remote this provider declined to\n * claim would make it the sole-configured fallback and aim PRs at the wrong host's repository.\n */\n isConfigured(remoteUrl?: string): boolean {\n if (remoteUrl && !isGitHubRemote(remoteUrl)) return false;\n if (remoteUrl) this.remoteHint = remoteUrl;\n return Boolean(this.resolveClient(remoteUrl));\n }\n\n async findPrByBranch(branch: string): Promise<PrInfo | undefined> {\n return this.requireClient().findPrByBranch(branch);\n }\n\n async createPr(opts: { head: string; base: string; title: string; body: string }): Promise<PrInfo> {\n return this.requireClient().createPr(opts);\n }\n\n async closePr(prNumber: number, comment?: string): Promise<void> {\n return this.requireClient().closePr(prNumber, comment);\n }\n\n async comment(prNumber: number, body: string): Promise<void> {\n return this.requireClient().comment(prNumber, body);\n }\n\n async addLabel(prNumber: number, label: string): Promise<void> {\n return this.requireClient().addLabel(prNumber, label);\n }\n\n async upsertComment(\n prNumber: number,\n marker: string,\n body: string,\n options?: { createIfAbsent?: boolean }\n ): Promise<void> {\n return this.requireClient().upsertComment(prNumber, marker, body, options);\n }\n\n private resolveClient(remoteUrl?: string): GitHubClient | undefined {\n if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning);\n return this.client;\n }\n\n private requireClient(): GitHubClient {\n const client = this.resolveClient();\n if (!client) {\n throw new Error(\n `the github host provider is not configured: set BIT_GITHUB_TOKEN (or GITHUB_TOKEN), and either ` +\n `GITHUB_REPOSITORY or a github.com \"origin\" remote`\n );\n }\n return client;\n }\n}\n"],"mappings":"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","ignoreList":[]}
|
|
@@ -200,6 +200,255 @@ describe('GitHubClient', () => {
|
|
|
200
200
|
});
|
|
201
201
|
(0, _chai().expect)(await client.findPrByBranch('lane-x')).to.equal(undefined);
|
|
202
202
|
});
|
|
203
|
+
describe('upsertComment', () => {
|
|
204
|
+
// named `upsertComment`, matching `GitHostProvider.upsertComment` exactly — see the method's
|
|
205
|
+
// own doc comment for why a differently-named method would be a silent-no-op trap.
|
|
206
|
+
function fakeFetchOver(existingComments) {
|
|
207
|
+
const calls = [];
|
|
208
|
+
const fetchImpl = async (url, init) => {
|
|
209
|
+
calls.push({
|
|
210
|
+
url: String(url),
|
|
211
|
+
init
|
|
212
|
+
});
|
|
213
|
+
const method = init?.method ?? 'GET';
|
|
214
|
+
if (method === 'GET') {
|
|
215
|
+
return new Response(JSON.stringify(existingComments), {
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: {
|
|
218
|
+
'content-type': 'application/json'
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return new Response(JSON.stringify({}), {
|
|
223
|
+
status: 200,
|
|
224
|
+
headers: {
|
|
225
|
+
'content-type': 'application/json'
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
calls,
|
|
231
|
+
fetchImpl
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
it('posts a new comment when no marked comment exists', async () => {
|
|
235
|
+
const {
|
|
236
|
+
calls,
|
|
237
|
+
fetchImpl
|
|
238
|
+
} = fakeFetchOver([]);
|
|
239
|
+
const client = new (_githubClient().GitHubClient)({
|
|
240
|
+
token: 'tok',
|
|
241
|
+
repo: 'acme/shop',
|
|
242
|
+
fetchImpl
|
|
243
|
+
});
|
|
244
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody');
|
|
245
|
+
const posts = calls.filter(c => c.init?.method === 'POST');
|
|
246
|
+
(0, _chai().expect)(posts).to.have.lengthOf(1);
|
|
247
|
+
(0, _chai().expect)(posts[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments');
|
|
248
|
+
});
|
|
249
|
+
it('patches the marked comment in place, and never posts a second one', async () => {
|
|
250
|
+
const {
|
|
251
|
+
calls,
|
|
252
|
+
fetchImpl
|
|
253
|
+
} = fakeFetchOver([{
|
|
254
|
+
id: 42,
|
|
255
|
+
body: 'unrelated comment'
|
|
256
|
+
}, {
|
|
257
|
+
id: 99,
|
|
258
|
+
body: '<!-- marker -->\nold report'
|
|
259
|
+
}]);
|
|
260
|
+
const client = new (_githubClient().GitHubClient)({
|
|
261
|
+
token: 'tok',
|
|
262
|
+
repo: 'acme/shop',
|
|
263
|
+
fetchImpl
|
|
264
|
+
});
|
|
265
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
|
|
266
|
+
const patches = calls.filter(c => c.init?.method === 'PATCH');
|
|
267
|
+
(0, _chai().expect)(patches).to.have.lengthOf(1);
|
|
268
|
+
(0, _chai().expect)(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99');
|
|
269
|
+
(0, _chai().expect)(JSON.parse(patches[0].init.body)).to.deep.equal({
|
|
270
|
+
body: '<!-- marker -->\nnew report'
|
|
271
|
+
});
|
|
272
|
+
(0, _chai().expect)(calls.some(c => c.init?.method === 'POST')).to.equal(false);
|
|
273
|
+
});
|
|
274
|
+
it('skips silently when createIfAbsent is false and no marked comment exists', async () => {
|
|
275
|
+
const {
|
|
276
|
+
calls,
|
|
277
|
+
fetchImpl
|
|
278
|
+
} = fakeFetchOver([]);
|
|
279
|
+
const client = new (_githubClient().GitHubClient)({
|
|
280
|
+
token: 'tok',
|
|
281
|
+
repo: 'acme/shop',
|
|
282
|
+
fetchImpl
|
|
283
|
+
});
|
|
284
|
+
await client.upsertComment(7, '<!-- marker -->', 'cleared', {
|
|
285
|
+
createIfAbsent: false
|
|
286
|
+
});
|
|
287
|
+
(0, _chai().expect)(calls.some(c => c.init?.method === 'POST' || c.init?.method === 'PATCH')).to.equal(false);
|
|
288
|
+
});
|
|
289
|
+
it('lists at per_page=100 and follows Link-header pagination to find a comment past page 1', async () => {
|
|
290
|
+
// GitHub defaults to per_page=30; a naive single-page list would miss this comment and post a
|
|
291
|
+
// duplicate report on every push instead of updating it.
|
|
292
|
+
const calls = [];
|
|
293
|
+
const fetchImpl = async (url, init) => {
|
|
294
|
+
calls.push({
|
|
295
|
+
url: String(url),
|
|
296
|
+
init
|
|
297
|
+
});
|
|
298
|
+
const method = init?.method ?? 'GET';
|
|
299
|
+
if (method === 'GET' && !String(url).includes('page=2')) {
|
|
300
|
+
return new Response(JSON.stringify([{
|
|
301
|
+
id: 1,
|
|
302
|
+
body: 'unrelated'
|
|
303
|
+
}]), {
|
|
304
|
+
status: 200,
|
|
305
|
+
headers: {
|
|
306
|
+
'content-type': 'application/json',
|
|
307
|
+
link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"'
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (method === 'GET') {
|
|
312
|
+
return new Response(JSON.stringify([{
|
|
313
|
+
id: 99,
|
|
314
|
+
body: '<!-- marker -->\nold report'
|
|
315
|
+
}]), {
|
|
316
|
+
status: 200,
|
|
317
|
+
headers: {
|
|
318
|
+
'content-type': 'application/json'
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return new Response(JSON.stringify({}), {
|
|
323
|
+
status: 200,
|
|
324
|
+
headers: {
|
|
325
|
+
'content-type': 'application/json'
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
const client = new (_githubClient().GitHubClient)({
|
|
330
|
+
token: 'tok',
|
|
331
|
+
repo: 'acme/shop',
|
|
332
|
+
fetchImpl
|
|
333
|
+
});
|
|
334
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
|
|
335
|
+
const gets = calls.filter(c => (c.init?.method ?? 'GET') === 'GET');
|
|
336
|
+
(0, _chai().expect)(gets).to.have.lengthOf(2);
|
|
337
|
+
(0, _chai().expect)(gets[0].url).to.include('per_page=100');
|
|
338
|
+
(0, _chai().expect)(gets[1].url).to.equal('https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2');
|
|
339
|
+
const patches = calls.filter(c => c.init?.method === 'PATCH');
|
|
340
|
+
(0, _chai().expect)(patches).to.have.lengthOf(1);
|
|
341
|
+
(0, _chai().expect)(patches[0].url).to.equal('https://api.github.com/repos/acme/shop/issues/comments/99');
|
|
342
|
+
// the found-on-page-2 comment was updated, not duplicated
|
|
343
|
+
(0, _chai().expect)(calls.some(c => c.init?.method === 'POST')).to.equal(false);
|
|
344
|
+
});
|
|
345
|
+
it('refuses to follow a Link header that leaves api.github.com — the token stays home', async () => {
|
|
346
|
+
// The next-page URL is response data, and requestRaw attaches the bearer token to whatever URL
|
|
347
|
+
// it is handed — an off-host "next" link must read as "last page", never as a request target.
|
|
348
|
+
const calls = [];
|
|
349
|
+
const fetchImpl = async (url, init) => {
|
|
350
|
+
calls.push({
|
|
351
|
+
url: String(url),
|
|
352
|
+
init
|
|
353
|
+
});
|
|
354
|
+
const method = init?.method ?? 'GET';
|
|
355
|
+
if (method === 'GET') {
|
|
356
|
+
return new Response(JSON.stringify([{
|
|
357
|
+
id: 1,
|
|
358
|
+
body: 'unrelated'
|
|
359
|
+
}]), {
|
|
360
|
+
status: 200,
|
|
361
|
+
headers: {
|
|
362
|
+
'content-type': 'application/json',
|
|
363
|
+
link: '<https://evil.test/repos/acme/shop/issues/7/comments?page=2>; rel="next"'
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return new Response(JSON.stringify({}), {
|
|
368
|
+
status: 200,
|
|
369
|
+
headers: {
|
|
370
|
+
'content-type': 'application/json'
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
const client = new (_githubClient().GitHubClient)({
|
|
375
|
+
token: 'tok',
|
|
376
|
+
repo: 'acme/shop',
|
|
377
|
+
fetchImpl
|
|
378
|
+
});
|
|
379
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody');
|
|
380
|
+
(0, _chai().expect)(calls.some(c => c.url.includes('evil.test'))).to.equal(false);
|
|
381
|
+
const gets = calls.filter(c => (c.init?.method ?? 'GET') === 'GET');
|
|
382
|
+
(0, _chai().expect)(gets, 'the off-host link is the last page, not a loop').to.have.lengthOf(1);
|
|
383
|
+
});
|
|
384
|
+
it('stops paginating as soon as the marked comment is seen', async () => {
|
|
385
|
+
const calls = [];
|
|
386
|
+
const fetchImpl = async (url, init) => {
|
|
387
|
+
calls.push({
|
|
388
|
+
url: String(url),
|
|
389
|
+
init
|
|
390
|
+
});
|
|
391
|
+
const method = init?.method ?? 'GET';
|
|
392
|
+
if (method === 'GET') {
|
|
393
|
+
return new Response(JSON.stringify([{
|
|
394
|
+
id: 99,
|
|
395
|
+
body: '<!-- marker -->\nold report'
|
|
396
|
+
}]), {
|
|
397
|
+
status: 200,
|
|
398
|
+
headers: {
|
|
399
|
+
'content-type': 'application/json',
|
|
400
|
+
link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"'
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return new Response(JSON.stringify({}), {
|
|
405
|
+
status: 200,
|
|
406
|
+
headers: {
|
|
407
|
+
'content-type': 'application/json'
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
const client = new (_githubClient().GitHubClient)({
|
|
412
|
+
token: 'tok',
|
|
413
|
+
repo: 'acme/shop',
|
|
414
|
+
fetchImpl
|
|
415
|
+
});
|
|
416
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nnew report');
|
|
417
|
+
const gets = calls.filter(c => (c.init?.method ?? 'GET') === 'GET');
|
|
418
|
+
(0, _chai().expect)(gets, 'the match on page 1 makes page 2 pointless').to.have.lengthOf(1);
|
|
419
|
+
(0, _chai().expect)(calls.filter(c => c.init?.method === 'PATCH')).to.have.lengthOf(1);
|
|
420
|
+
});
|
|
421
|
+
it('exhausting the page budget throws instead of posting a duplicate', async () => {
|
|
422
|
+
// "Ran out of budget" must not read as "absent": a POST here would add a duplicate report on
|
|
423
|
+
// every later push. The throw surfaces through the caller's existing warn-and-skip path.
|
|
424
|
+
const calls = [];
|
|
425
|
+
const fetchImpl = async (url, init) => {
|
|
426
|
+
calls.push({
|
|
427
|
+
url: String(url),
|
|
428
|
+
init
|
|
429
|
+
});
|
|
430
|
+
return new Response(JSON.stringify([{
|
|
431
|
+
id: 1,
|
|
432
|
+
body: 'unrelated'
|
|
433
|
+
}]), {
|
|
434
|
+
status: 200,
|
|
435
|
+
headers: {
|
|
436
|
+
'content-type': 'application/json',
|
|
437
|
+
link: '<https://api.github.com/repos/acme/shop/issues/7/comments?per_page=100&page=2>; rel="next"'
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
const client = new (_githubClient().GitHubClient)({
|
|
442
|
+
token: 'tok',
|
|
443
|
+
repo: 'acme/shop',
|
|
444
|
+
fetchImpl
|
|
445
|
+
});
|
|
446
|
+
let thrown;
|
|
447
|
+
await client.upsertComment(7, '<!-- marker -->', '<!-- marker -->\nbody').catch(e => thrown = e);
|
|
448
|
+
(0, _chai().expect)(thrown, 'the budget exhaustion is an error, not an absence').to.be.an('error');
|
|
449
|
+
(0, _chai().expect)(calls.some(c => c.init?.method === 'POST')).to.equal(false);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
203
452
|
});
|
|
204
453
|
|
|
205
454
|
//# sourceMappingURL=github-client.spec.js.map
|