@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
@@ -1,5 +1,5 @@
1
1
  ;
2
- import * as overview_0 from '/home/circleci/Library/Caches/Bit/capsules/8891be5ad/teambit.git_ci@1.0.507/dist/ci.docs.mdx';
2
+ import * as overview_0 from '/home/circleci/Library/Caches/Bit/capsules/8891be5ad/teambit.git_ci@1.0.508/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;;AAiCA;;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":[]}
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
- async request(method, path, body) {
86
- const res = await this.fetchImpl(`${API}/repos/${this.repo}${path}`, {
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} ${path} failed: ${res.status} ${text}`);
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