@teambit/ci 1.0.485 → 1.0.487

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 (97) hide show
  1. package/ci.docs.mdx +344 -83
  2. package/commands/sync.cmd.ts +77 -0
  3. package/dist/ci.docs.mdx +344 -83
  4. package/dist/ci.main.runtime.d.ts +67 -6
  5. package/dist/ci.main.runtime.js +152 -25
  6. package/dist/ci.main.runtime.js.map +1 -1
  7. package/dist/commands/sync.cmd.d.ts +34 -0
  8. package/dist/commands/sync.cmd.js +57 -0
  9. package/dist/commands/sync.cmd.js.map +1 -0
  10. package/dist/git.d.ts +11 -1
  11. package/dist/git.js +11 -1
  12. package/dist/git.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +26 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/{preview-1785858957533.js → preview-1785883495880.js} +1 -1
  17. package/dist/sync/git-host-provider.d.ts +47 -0
  18. package/dist/sync/git-host-provider.js +54 -0
  19. package/dist/sync/git-host-provider.js.map +1 -0
  20. package/dist/sync/git-host-provider.spec.d.ts +1 -0
  21. package/dist/sync/git-host-provider.spec.js +171 -0
  22. package/dist/sync/git-host-provider.spec.js.map +1 -0
  23. package/dist/sync/git-ops.d.ts +171 -0
  24. package/dist/sync/git-ops.js +354 -0
  25. package/dist/sync/git-ops.js.map +1 -0
  26. package/dist/sync/git-ops.spec.d.ts +1 -0
  27. package/dist/sync/git-ops.spec.js +476 -0
  28. package/dist/sync/git-ops.spec.js.map +1 -0
  29. package/dist/sync/github-client.d.ts +79 -0
  30. package/dist/sync/github-client.js +198 -0
  31. package/dist/sync/github-client.js.map +1 -0
  32. package/dist/sync/github-client.spec.d.ts +1 -0
  33. package/dist/sync/github-client.spec.js +205 -0
  34. package/dist/sync/github-client.spec.js.map +1 -0
  35. package/dist/sync/init-scaffold.d.ts +40 -0
  36. package/dist/sync/init-scaffold.js +326 -0
  37. package/dist/sync/init-scaffold.js.map +1 -0
  38. package/dist/sync/init-scaffold.spec.d.ts +1 -0
  39. package/dist/sync/init-scaffold.spec.js +169 -0
  40. package/dist/sync/init-scaffold.spec.js.map +1 -0
  41. package/dist/sync/lane-sync-executor.d.ts +256 -0
  42. package/dist/sync/lane-sync-executor.js +1249 -0
  43. package/dist/sync/lane-sync-executor.js.map +1 -0
  44. package/dist/sync/lane-sync-executor.spec.d.ts +1 -0
  45. package/dist/sync/lane-sync-executor.spec.js +416 -0
  46. package/dist/sync/lane-sync-executor.spec.js.map +1 -0
  47. package/dist/sync/main-sync-executor.d.ts +62 -0
  48. package/dist/sync/main-sync-executor.js +305 -0
  49. package/dist/sync/main-sync-executor.js.map +1 -0
  50. package/dist/sync/sync-config.d.ts +83 -0
  51. package/dist/sync/sync-config.js +187 -0
  52. package/dist/sync/sync-config.js.map +1 -0
  53. package/dist/sync/sync-config.spec.d.ts +1 -0
  54. package/dist/sync/sync-config.spec.js +254 -0
  55. package/dist/sync/sync-config.spec.js.map +1 -0
  56. package/dist/sync/sync-orchestrator.d.ts +76 -0
  57. package/dist/sync/sync-orchestrator.js +396 -0
  58. package/dist/sync/sync-orchestrator.js.map +1 -0
  59. package/dist/sync/sync-orchestrator.spec.d.ts +1 -0
  60. package/dist/sync/sync-orchestrator.spec.js +155 -0
  61. package/dist/sync/sync-orchestrator.spec.js.map +1 -0
  62. package/dist/sync/sync-planner.d.ts +60 -0
  63. package/dist/sync/sync-planner.js +114 -0
  64. package/dist/sync/sync-planner.js.map +1 -0
  65. package/dist/sync/sync-planner.spec.d.ts +1 -0
  66. package/dist/sync/sync-planner.spec.js +210 -0
  67. package/dist/sync/sync-planner.spec.js.map +1 -0
  68. package/dist/sync/sync-state.d.ts +94 -0
  69. package/dist/sync/sync-state.js +178 -0
  70. package/dist/sync/sync-state.js.map +1 -0
  71. package/dist/sync/sync-state.spec.d.ts +1 -0
  72. package/dist/sync/sync-state.spec.js +271 -0
  73. package/dist/sync/sync-state.spec.js.map +1 -0
  74. package/dist/sync/workspace-lane.d.ts +13 -0
  75. package/dist/sync/workspace-lane.js +27 -0
  76. package/dist/sync/workspace-lane.js.map +1 -0
  77. package/package.json +20 -15
  78. package/sync/git-host-provider.spec.ts +197 -0
  79. package/sync/git-host-provider.ts +89 -0
  80. package/sync/git-ops.spec.ts +506 -0
  81. package/sync/git-ops.ts +334 -0
  82. package/sync/github-client.spec.ts +174 -0
  83. package/sync/github-client.ts +209 -0
  84. package/sync/init-scaffold.spec.ts +142 -0
  85. package/sync/init-scaffold.ts +340 -0
  86. package/sync/lane-sync-executor.spec.ts +389 -0
  87. package/sync/lane-sync-executor.ts +1363 -0
  88. package/sync/main-sync-executor.ts +321 -0
  89. package/sync/sync-config.spec.ts +283 -0
  90. package/sync/sync-config.ts +214 -0
  91. package/sync/sync-orchestrator.spec.ts +120 -0
  92. package/sync/sync-orchestrator.ts +386 -0
  93. package/sync/sync-planner.spec.ts +126 -0
  94. package/sync/sync-planner.ts +99 -0
  95. package/sync/sync-state.spec.ts +263 -0
  96. package/sync/sync-state.ts +176 -0
  97. package/sync/workspace-lane.ts +20 -0
@@ -0,0 +1,334 @@
1
+ import { BitError } from '@teambit/bit-error';
2
+ import { git } from '../git';
3
+ import { validateBranchName } from './sync-config';
4
+
5
+ /**
6
+ * Git primitives shared by the lane and main sync executors; each encodes a decision that must stay
7
+ * identical on both paths.
8
+ */
9
+
10
+ /** paths the sync executors never treat as workspace content — see `cleanUntrackedScoped` */
11
+ export const SYNC_EXCLUDED_PATHS = ['.bit', 'node_modules'];
12
+
13
+ /**
14
+ * How a raw git invocation is run, injectable so a helper whose whole content is its argv can be unit
15
+ * tested without a repository. Production always uses {@link realGitRaw}.
16
+ */
17
+ export type GitArgsRunner = (args: string[]) => Promise<unknown>;
18
+
19
+ export const realGitRaw: GitArgsRunner = (args) => git.raw(args);
20
+
21
+ /**
22
+ * Remove untracked files, leaving the local bit scope and installed packages alone. No `-x` (ignored
23
+ * files stay), and `.bit` / `node_modules` are excluded explicitly — without that, the clean deletes
24
+ * the local scope in a workspace whose `.gitignore` lacks Bit's block.
25
+ */
26
+ export async function cleanUntrackedScoped(run: GitArgsRunner = realGitRaw): Promise<void> {
27
+ await run(['clean', '-fd', ...SYNC_EXCLUDED_PATHS.flatMap((path) => ['-e', path])]);
28
+ }
29
+
30
+ /**
31
+ * Refuse a branch name git would read as an option or reject as a ref. Not every branch reaching the
32
+ * checkout comes from validated config: the default branch is derived from the remote
33
+ * (`getDefaultBranchName`), so the primitive that builds the argv is where the guard belongs — every
34
+ * caller inherits it.
35
+ */
36
+ export function assertCheckoutableBranch(branch: string): void {
37
+ const problem = validateBranchName(branch);
38
+ if (problem) {
39
+ throw new BitError(
40
+ `bit ci sync cannot check out "${branch}": ${problem}. Rename the branch, or set the intended name ` +
41
+ `explicitly in the "teambit.git/ci" sync config.`
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Put the working tree on `branch` holding nothing but that commit's content, then make the workspace
48
+ * re-read the checked-out `.bitmap`. Order matters: `checkout -f` (leftover tracked changes must not
49
+ * abort the target), then the scoped clean (a forced checkout leaves untracked files in place, and
50
+ * every commit path stages with `-A`), then the reload so it reads the pristine tree. With
51
+ * `startPoint`, `-B` creates-or-resets the branch — refused when the local branch holds commits no
52
+ * remote contains, because moving it would orphan unpushed work; the predicate is remote containment,
53
+ * not start-point ancestry (a fully pushed tip loses nothing in either legitimate reset shape).
54
+ */
55
+ export async function checkoutPristine(
56
+ branch: string,
57
+ startPoint: string | undefined,
58
+ reload: () => Promise<void>,
59
+ run: GitArgsRunner = realGitRaw
60
+ ): Promise<void> {
61
+ assertCheckoutableBranch(branch);
62
+ if (startPoint && (await localBranchExists(branch, run))) {
63
+ const containedIn = String((await run(['branch', '-r', '--contains', `refs/heads/${branch}`])) ?? '').trim();
64
+ if (!containedIn) {
65
+ throw new Error(
66
+ `local branch "${branch}" has commits that no remote branch contains — resetting it would ` +
67
+ `orphan them (recoverable only from the reflog). Push or back up the local branch, delete ` +
68
+ `it, or run from a clean checkout`
69
+ );
70
+ }
71
+ }
72
+ await run(startPoint ? ['checkout', '-f', '-B', branch, startPoint] : ['checkout', '-f', branch]);
73
+ await cleanUntrackedScoped(run);
74
+ await reload();
75
+ }
76
+
77
+ /**
78
+ * Whether the branch exists locally. Judged by output, never by whether the call threw: simple-git's
79
+ * `raw` resolves with empty output on some non-zero exits instead of rejecting.
80
+ */
81
+ export async function localBranchExists(branch: string, run: GitArgsRunner = realGitRaw): Promise<boolean> {
82
+ const out = await run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]).catch(() => '');
83
+ return String(out ?? '').trim().length > 0;
84
+ }
85
+
86
+ /**
87
+ * `checkoutPristine` for the restore paths: a start point only when the branch is absent locally
88
+ * (detached-HEAD/single-branch checkouts) — passing `origin/<branch>` when it exists would `-B`-reset
89
+ * the developer's default branch and discard their unpushed commits.
90
+ */
91
+ export async function checkoutPristineRestore(
92
+ branch: string,
93
+ reload: () => Promise<void>,
94
+ run: GitArgsRunner = realGitRaw
95
+ ): Promise<void> {
96
+ const startPoint = (await localBranchExists(branch, run)) ? undefined : `origin/${branch}`;
97
+ await checkoutPristine(branch, startPoint, reload, run);
98
+ }
99
+
100
+ /**
101
+ * Stage every change except `.bit` / `node_modules` (a bare `add -A` would commit the local scope when
102
+ * `.gitignore` lacks Bit's block). The `:(exclude)` pathspecs make git skip those trees instead of
103
+ * staging them for a reset to undo; `git add` refuses them when a named path IS ignored — the case where
104
+ * the traversal is already cheap — so the add-then-reset pair is the fallback. Both leave the same index,
105
+ * and `git reset -- <paths>` is a no-op for paths absent from it, so neither fallback command can fail.
106
+ */
107
+ export async function addAllExceptScopeAndModules(run: GitArgsRunner = realGitRaw): Promise<void> {
108
+ const stageAll = ['add', '-A', '--', '.'];
109
+ try {
110
+ await run([...stageAll, ...SYNC_EXCLUDED_PATHS.map((path) => `:(exclude)${path}`)]);
111
+ } catch {
112
+ await run(stageAll);
113
+ await run(['reset', '-q', '--', ...SYNC_EXCLUDED_PATHS]);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Whether a `git status` path is one the sync executors never treat as workspace content. Applied
119
+ * everywhere a status is interpreted — otherwise `.bit`/`node_modules` files count as drift when
120
+ * `.gitignore` lacks Bit's block.
121
+ */
122
+ export function isNonContentPath(path: string): boolean {
123
+ return SYNC_EXCLUDED_PATHS.some((excluded) => path === excluded || path.startsWith(`${excluded}/`));
124
+ }
125
+
126
+ /**
127
+ * The refspec every sync fetch passes **explicitly**, overriding whatever `remote.origin.fetch` this
128
+ * checkout was configured with.
129
+ */
130
+ export const ALL_HEADS_REFSPEC = '+refs/heads/*:refs/remotes/origin/*';
131
+
132
+ /**
133
+ * Fetch `origin` so every branch it has is available as `refs/remotes/origin/<branch>`. A bare fetch
134
+ * honours the checkout's configured `remote.origin.fetch`, which in a single-branch clone updates
135
+ * exactly one ref while enumeration (`ls-remote`) sees every branch; an explicit refspec overrides the
136
+ * config. Does not make shallow clones work — depth is a different axis.
137
+ */
138
+ export async function fetchRemoteHeads(run: GitArgsRunner = realGitRaw): Promise<void> {
139
+ await run(['fetch', 'origin', ALL_HEADS_REFSPEC]);
140
+ }
141
+
142
+ /** Refspec that updates exactly one remote-tracking ref — a mid-run re-read of a single branch. */
143
+ export function singleHeadRefspec(branch: string): string {
144
+ return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
145
+ }
146
+
147
+ /**
148
+ * `origin/<branch>`'s tip as the remote has it NOW: every other ref a run reads comes from one fetch at
149
+ * its start. Undefined when the branch is gone or unreadable — never a stale sha, because the answer
150
+ * decides whether a branch deletion is still safe.
151
+ */
152
+ export async function refetchBranchTip(branch: string, run: GitArgsRunner = realGitRaw): Promise<string | undefined> {
153
+ try {
154
+ await run(['fetch', 'origin', singleHeadRefspec(branch)]);
155
+ const out = await run(['rev-parse', `refs/remotes/origin/${branch}`]);
156
+ return String(out ?? '').trim() || undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * `git push` argv that deletes a remote branch only while its tip is still `expectedTipSha`. Full
164
+ * `:refs/heads/<b>` refspec so the branch name can never be read as an option; the lease makes the server
165
+ * itself refuse a racing update rather than lose it.
166
+ */
167
+ export function deleteBranchArgs(branch: string, expectedTipSha: string): string[] {
168
+ return ['origin', `--force-with-lease=refs/heads/${branch}:${expectedTipSha}`, `:refs/heads/${branch}`];
169
+ }
170
+
171
+ /**
172
+ * Whether a failed push was the lease refusing. The wording varies by git version and transport:
173
+ * "stale info" (client-side check), "is at X but expected Y" (receive-pack), and "incorrect old value
174
+ * provided" (the local/file transport, which is what CI's e2e remotes use). Over-matching is the safe
175
+ * direction — the only consequence is reporting a branch as kept rather than as a failed delete.
176
+ */
177
+ export function isStaleLeaseRejection(message: string): boolean {
178
+ return /stale info|force-with-lease|but expected|incorrect old value/i.test(message);
179
+ }
180
+
181
+ /** Whether `origin` has the given branch. Assumes a `git fetch` isn't required (uses `ls-remote`). */
182
+ export async function branchExistsOnRemote(branch: string): Promise<boolean> {
183
+ const out = await git.raw(['ls-remote', '--heads', 'origin', branch]);
184
+ return out.trim().length > 0;
185
+ }
186
+
187
+ /**
188
+ * Every branch `origin` has, by short name. `ls-remote` rather than `git branch -r` so the answer
189
+ * cannot be narrowed by the checkout's refspec or staleness.
190
+ */
191
+ export async function listRemoteBranches(): Promise<string[]> {
192
+ const out = await git.raw(['ls-remote', '--heads', 'origin']);
193
+ const prefix = 'refs/heads/';
194
+ return out
195
+ .split('\n')
196
+ .map((line) => line.split('\t')[1]?.trim())
197
+ .filter((ref): ref is string => Boolean(ref && ref.startsWith(prefix)))
198
+ .map((ref) => ref.slice(prefix.length));
199
+ }
200
+
201
+ /**
202
+ * Is `maybeAncestor` reachable from `descendant` (true for identical commits)? Not `merge-base
203
+ * --is-ancestor`: it answers via the exit code, and simple-git's `raw` resolves on exit 1, so "not an
204
+ * ancestor" would read as yes. Throws when the commits share no history or a revision cannot resolve.
205
+ */
206
+ export async function isAncestor(maybeAncestor: string, descendant: string): Promise<boolean> {
207
+ const ancestorSha = (await git.revparse([maybeAncestor])).trim();
208
+ const mergeBase = (await git.raw(['merge-base', maybeAncestor, descendant])).trim();
209
+ if (!ancestorSha || !mergeBase) throw new Error(`could not compare ${maybeAncestor} against ${descendant}`);
210
+ return ancestorSha === mergeBase;
211
+ }
212
+
213
+ /** The prefix `git symbolic-ref refs/remotes/origin/HEAD` puts in front of the default branch name. */
214
+ export const ORIGIN_HEAD_REF_PREFIX = 'refs/remotes/origin/';
215
+
216
+ /**
217
+ * The default branch name out of `git symbolic-ref refs/remotes/origin/HEAD` output, or undefined for
218
+ * an unrecognised shape. Strips the prefix; never splits on `/` — branch names may contain slashes
219
+ * (`release/main`), and truncating would protect the wrong branch.
220
+ */
221
+ export function parseOriginHeadRef(symbolicRefOutput: string): string | undefined {
222
+ const ref = symbolicRefOutput.trim();
223
+ if (!ref.startsWith(ORIGIN_HEAD_REF_PREFIX)) return undefined;
224
+ return ref.slice(ORIGIN_HEAD_REF_PREFIX.length) || undefined;
225
+ }
226
+
227
+ /** The prefix a symref answer from `git ls-remote --symref origin HEAD` puts before the branch name. */
228
+ const LS_REMOTE_SYMREF_PREFIX = 'ref: refs/heads/';
229
+
230
+ /**
231
+ * The default branch name out of `git ls-remote --symref origin HEAD` output, or undefined when no
232
+ * symref line is present (some servers omit it). Same slash discipline as {@link parseOriginHeadRef}.
233
+ */
234
+ export function parseLsRemoteSymref(lsRemoteOutput: string): string | undefined {
235
+ const line = lsRemoteOutput.split('\n').find((l) => l.startsWith(LS_REMOTE_SYMREF_PREFIX));
236
+ if (!line) return undefined;
237
+ const name = line.slice(LS_REMOTE_SYMREF_PREFIX.length).split('\t')[0].trim();
238
+ return name || undefined;
239
+ }
240
+
241
+ /**
242
+ * The remote's own answer for its default branch. Unlike the local `origin/HEAD` symref or enumerating
243
+ * `origin/*`, this cannot be stale or narrowed — only unanswered (undefined hands on to the fallback).
244
+ */
245
+ export async function remoteHeadBranch(run: GitArgsRunner = realGitRaw): Promise<string | undefined> {
246
+ try {
247
+ const out = await run(['ls-remote', '--symref', 'origin', 'HEAD']);
248
+ return parseLsRemoteSymref(String(out ?? ''));
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * The git repository's root, or undefined outside a repo. A bit workspace is not necessarily the repo
256
+ * root, and GitHub only discovers workflows at `<repo>/.github/workflows`.
257
+ */
258
+ export async function gitRepoRoot(): Promise<string | undefined> {
259
+ try {
260
+ const root = (await git.raw(['rev-parse', '--show-toplevel'])).trim();
261
+ return root || undefined;
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Reading `git config`, injectable so the identity decision can be unit tested without a repository.
269
+ * There is deliberately no writer — see {@link resolveGitIdentity}.
270
+ */
271
+ export type GitConfigIO = {
272
+ /** the configured value, or undefined when unset (or when git cannot answer) */
273
+ get(key: string): Promise<string | undefined>;
274
+ };
275
+
276
+ export const realGitConfigIO: GitConfigIO = {
277
+ get: (key) =>
278
+ git
279
+ .raw(['config', '--get', key])
280
+ .then((out) => (out.trim().length ? out.trim() : undefined))
281
+ // A missing key and "not a git repository" both exit non-zero; both mean "no identity to keep".
282
+ .catch(() => undefined),
283
+ };
284
+
285
+ /**
286
+ * The identity the sync commits with when nothing else says otherwise. A contract: the scaffolded
287
+ * workflow templates (`init-scaffold.ts`) and the GitHub Action's defaults must carry the same pair,
288
+ * or the same repository commits under different authors depending on entry point.
289
+ */
290
+ export const DEFAULT_GIT_USER_NAME = 'bit-sync[bot]';
291
+ export const DEFAULT_GIT_USER_EMAIL = 'bit-sync[bot]@users.noreply.github.com';
292
+
293
+ export type GitIdentity = { name: string; email: string };
294
+
295
+ /**
296
+ * Who the sync commits as. Read-only on purpose: writing these keys into the repository's config
297
+ * would outlive the run and re-author the developer's own later commits as the bot. Precedence per
298
+ * key: configured git identity > `GIT_USER_NAME`/`GIT_USER_EMAIL` > default.
299
+ */
300
+ export async function resolveGitIdentity(
301
+ io: GitConfigIO = realGitConfigIO,
302
+ env: NodeJS.ProcessEnv = process.env
303
+ ): Promise<GitIdentity> {
304
+ return {
305
+ name: (await io.get('user.name')) || env.GIT_USER_NAME || DEFAULT_GIT_USER_NAME,
306
+ email: (await io.get('user.email')) || env.GIT_USER_EMAIL || DEFAULT_GIT_USER_EMAIL,
307
+ };
308
+ }
309
+
310
+ /** The `git -c` pair that applies an identity to ONE invocation, leaving the repo's config alone. */
311
+ export function identityArgs(identity: GitIdentity): string[] {
312
+ return ['-c', `user.name=${identity.name}`, '-c', `user.email=${identity.email}`];
313
+ }
314
+
315
+ export type IdentityDeps = { run?: GitArgsRunner; io?: GitConfigIO; env?: NodeJS.ProcessEnv };
316
+
317
+ /**
318
+ * Run a git command that may author a commit. Every such command needs the identity — a fresh CI
319
+ * checkout has none, and git refuses to commit ("Please tell me who you are") without one.
320
+ */
321
+ export async function gitWithIdentity(args: string[], deps: IdentityDeps = {}): Promise<string> {
322
+ const { run = realGitRaw, io = realGitConfigIO, env = process.env } = deps;
323
+ const identity = await resolveGitIdentity(io, env);
324
+ return String((await run([...identityArgs(identity), ...args])) ?? '');
325
+ }
326
+
327
+ /** `git commit` under the sync identity; `extraArgs` carries flags such as `--allow-empty`. */
328
+ export async function commitWithIdentity(
329
+ message: string,
330
+ opts: IdentityDeps & { extraArgs?: string[] } = {}
331
+ ): Promise<void> {
332
+ const { extraArgs = [], ...deps } = opts;
333
+ await gitWithIdentity(['commit', '-m', message, ...extraArgs], deps);
334
+ }
@@ -0,0 +1,174 @@
1
+ import { expect } from 'chai';
2
+ import { GitHubClient, isGitHubRemote, parseGitHubRepo } from './github-client';
3
+
4
+ /**
5
+ * A claimed remote the parse then fails on silently degrades the whole run to PR-less mode, so the two
6
+ * predicates are pinned over the same remote shapes: whatever `isGitHubRemote` claims must parse.
7
+ */
8
+ const REMOTES: Array<[string, string | undefined, boolean]> = [
9
+ // url, parseGitHubRepo, isGitHubRemote
10
+ ['git@github.com:acme/shop.git', 'acme/shop', true],
11
+ ['https://github.com/acme/shop.git', 'acme/shop', true],
12
+ ['https://github.com/acme/shop', 'acme/shop', true],
13
+ ['ssh://git@github.com/acme/shop.git', 'acme/shop', true],
14
+ // mixed-case host, an explicit port, and credentials in the url are all still github.com
15
+ ['https://GitHub.com/acme/shop.git', 'acme/shop', true],
16
+ ['https://github.com:443/acme/shop.git', 'acme/shop', true],
17
+ ['ssh://git@github.com:22/acme/shop.git', 'acme/shop', true],
18
+ ['https://x-access-token:tok@github.com/acme/shop.git', 'acme/shop', true],
19
+ // an all-digits scp owner is an owner, never a port
20
+ ['git@github.com:12345/shop.git', '12345/shop', true],
21
+ // another host that merely has "github.com" in its path, in both url forms
22
+ ['https://gitlab.example.com/mirrors/github.com/acme/repo.git', undefined, false],
23
+ ['git@gitlab.example.com:mirrors/github.com/acme/repo.git', undefined, false],
24
+ // look-alike and unrelated hosts
25
+ ['https://mygithub.com/acme/shop', undefined, false],
26
+ ['https://github.company.com/acme/shop', undefined, false],
27
+ ['git@gitlab.com:acme/shop.git', undefined, false],
28
+ ['https://gitlab.com/acme/shop.git', undefined, false],
29
+ ['/srv/git/github.com/acme/shop.git', undefined, false],
30
+ ];
31
+
32
+ describe('parseGitHubRepo / isGitHubRemote', () => {
33
+ it('agree on every remote form git accepts, and on every host that is not github.com', () => {
34
+ REMOTES.forEach(([url, repo, isGitHub]) => {
35
+ expect(parseGitHubRepo(url), url).to.equal(repo);
36
+ expect(isGitHubRemote(url), url).to.equal(isGitHub);
37
+ });
38
+ });
39
+ });
40
+
41
+ describe('GitHubClient.fromEnv', () => {
42
+ const envKeys = ['GITHUB_TOKEN', 'BIT_GITHUB_TOKEN', 'GITHUB_REPOSITORY'] as const;
43
+ const saved: Record<string, string | undefined> = {};
44
+
45
+ beforeEach(() => {
46
+ envKeys.forEach((key) => {
47
+ saved[key] = process.env[key];
48
+ delete process.env[key];
49
+ });
50
+ process.env.GITHUB_TOKEN = 'tok';
51
+ });
52
+
53
+ afterEach(() => {
54
+ envKeys.forEach((key) => {
55
+ if (saved[key] === undefined) delete process.env[key];
56
+ else process.env[key] = saved[key];
57
+ });
58
+ });
59
+
60
+ const OURS = 'git@github.com:acme/shop.git';
61
+ /** [case, GITHUB_REPOSITORY, remote, resolved repo, warning fragments]. */
62
+ const RESOLUTION: Array<[string, string, string | undefined, string | undefined, string[]?]> = [
63
+ ['the remote and the environment agree', 'acme/shop', OURS, 'acme/shop'],
64
+ // the ORIGIN-parsed repository outranks GITHUB_REPOSITORY, and the disagreement must be reported
65
+ ['they disagree', 'other-org/other-repo', OURS, 'acme/shop', ['other-org/other-repo', 'acme/shop']],
66
+ // GitHub repository names are case-insensitive, so a case-only difference is not a disagreement
67
+ ['they differ only in case', 'Acme/Shop', OURS, 'acme/shop'],
68
+ ['there is no remote to parse', 'acme/shop', undefined, 'acme/shop'],
69
+ ['the remote is a github url nothing parses out of', 'acme/shop', 'https://github.com/', 'acme/shop'],
70
+ // a remote that is demonstrably not github stays unconfigured however complete the environment is
71
+ ['the remote is another host', 'acme/shop', 'https://gitlab.com/acme/shop.git', undefined],
72
+ ['the remote is a look-alike host', 'acme/shop', 'https://mygithub.com/acme/shop', undefined],
73
+ ];
74
+
75
+ RESOLUTION.forEach(([name, repository, remote, repo, warns]) => {
76
+ it(`resolves the repository when ${name}`, () => {
77
+ process.env.GITHUB_REPOSITORY = repository;
78
+ const warnings: string[] = [];
79
+ const client = GitHubClient.fromEnv(remote, (message) => warnings.push(message));
80
+ expect(client?.repo).to.equal(repo);
81
+ (warns ?? []).forEach((fragment) => expect(warnings.join('\n')).to.contain(fragment));
82
+ if (!warns) expect(warnings).to.deep.equal([]);
83
+ });
84
+ });
85
+
86
+ it('needs a token: a repository on its own configures nothing', () => {
87
+ delete process.env.GITHUB_TOKEN;
88
+ process.env.GITHUB_REPOSITORY = 'acme/shop';
89
+ expect(GitHubClient.fromEnv('git@github.com:acme/shop.git')).to.equal(undefined);
90
+ });
91
+
92
+ // Asserted through the `authorization` header, so what is pinned is the token the client actually
93
+ // sends. The fetch stub must be installed before `fromEnv` runs.
94
+ describe('token precedence', () => {
95
+ let realFetch: typeof fetch;
96
+ let calls: Array<{ url: string; init: any }>;
97
+
98
+ beforeEach(() => {
99
+ realFetch = globalThis.fetch;
100
+ calls = [];
101
+ globalThis.fetch = (async (url: any, init: any) => {
102
+ calls.push({ url: String(url), init });
103
+ return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' } });
104
+ }) as typeof fetch;
105
+ });
106
+
107
+ afterEach(() => {
108
+ globalThis.fetch = realFetch;
109
+ });
110
+
111
+ /** The token a client built from this environment puts on the wire. */
112
+ async function sentToken(): Promise<string | undefined> {
113
+ const client = GitHubClient.fromEnv('git@github.com:acme/shop.git');
114
+ await client?.findPrByBranch('lane-x');
115
+ return calls[0]?.init?.headers?.authorization;
116
+ }
117
+
118
+ const PRECEDENCE: Array<[string, Record<string, string | undefined>, string]> = [
119
+ // the override beats the ambient default
120
+ ['both set', { GITHUB_TOKEN: 'workflow-token', BIT_GITHUB_TOKEN: 'user-pat' }, 'Bearer user-pat'],
121
+ // the ordinary Actions job
122
+ ['only GITHUB_TOKEN', { GITHUB_TOKEN: 'workflow-token' }, 'Bearer workflow-token'],
123
+ // any CI that is not Actions
124
+ ['only BIT_GITHUB_TOKEN', { BIT_GITHUB_TOKEN: 'user-pat' }, 'Bearer user-pat'],
125
+ ['an EMPTY override', { GITHUB_TOKEN: 'workflow-token', BIT_GITHUB_TOKEN: '' }, 'Bearer workflow-token'],
126
+ ];
127
+
128
+ PRECEDENCE.forEach(([name, env, expected]) => {
129
+ it(`sends the right token with ${name}`, async () => {
130
+ delete process.env.GITHUB_TOKEN;
131
+ Object.entries(env).forEach(([key, value]) => {
132
+ process.env[key] = value;
133
+ });
134
+ expect(await sentToken()).to.equal(expected);
135
+ });
136
+ });
137
+
138
+ it('is not configured when neither token is set, and puts nothing on the wire', async () => {
139
+ delete process.env.GITHUB_TOKEN;
140
+ process.env.GITHUB_REPOSITORY = 'acme/shop';
141
+ expect(GitHubClient.fromEnv('git@github.com:acme/shop.git')).to.equal(undefined);
142
+ expect(calls).to.have.lengthOf(0);
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('GitHubClient', () => {
148
+ it('createPr posts the pull-request payload to the pulls endpoint', async () => {
149
+ const calls: Array<{ url: string; init: any }> = [];
150
+ const fakeFetch = (async (url: any, init: any) => {
151
+ calls.push({ url: String(url), init });
152
+ return new Response(
153
+ JSON.stringify({ number: 7, state: 'open', labels: [], head: { ref: 'lane-x' }, html_url: 'http://pr/7' }),
154
+ { status: 201, headers: { 'content-type': 'application/json' } }
155
+ );
156
+ }) as typeof fetch;
157
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl: fakeFetch });
158
+ const pr = await client.createPr({ head: 'lane-x', base: 'main', title: 't', body: 'b' });
159
+ expect(pr.number).to.equal(7);
160
+ expect(calls[0].url).to.equal('https://api.github.com/repos/acme/shop/pulls');
161
+ expect(calls[0].init.method).to.equal('POST');
162
+ expect(JSON.parse(calls[0].init.body)).to.deep.equal({ head: 'lane-x', base: 'main', title: 't', body: 'b' });
163
+ });
164
+
165
+ it('findPrByBranch returns undefined on empty list', async () => {
166
+ const fakeFetch = (async () =>
167
+ new Response(JSON.stringify([]), {
168
+ status: 200,
169
+ headers: { 'content-type': 'application/json' },
170
+ })) as typeof fetch;
171
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/shop', fetchImpl: fakeFetch });
172
+ expect(await client.findPrByBranch('lane-x')).to.equal(undefined);
173
+ });
174
+ });