1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import { assert, describe, it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { ChildProcessSpawner } from "effect/process";
12import * as TestClock from "effect/testing/TestClock";
13
14import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
15import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
16import * as GitHubApi from "./GitHubApi.ts";
17import * as GitHubCredentials from "./GitHubCredentials.ts";
18import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
19import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
20
21const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
22
23const processResult = (
24 stdout: string,
25 options?: {
26 readonly stderr?: string;
27 readonly exitCode?: ChildProcessSpawner.ExitCode;
28 },
29): SourceControlHost.SourceControlProcessOutput => ({
30 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
31 stdout,
32 stderr: options?.stderr ?? "",
33 stdoutTruncated: false,
34 stderrTruncated: false,
35});
36
37const processOutput = (
38 stdout: string,
39 exitCode = 0,
40): SourceControlHost.SourceControlProcessOutput => ({
41 exitCode: ChildProcessSpawner.ExitCode(exitCode),
42 stdout,
43 stderr: "",
44 stdoutTruncated: false,
45 stderrTruncated: false,
46});
47
48const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
49 entries
50 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
51 .join("\n");
52
53const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({
54 status,
55 headers: {},
56 body: body === undefined ? "" : encodeJson(body),
57 truncated: false,
58 invalidUtf8: false,
59});
60
61const node = (number: number, headRefName: string, owner = "acme") => ({
62 number,
63 title: `PR ${number}`,
64 url: `https://github.com/acme/web/pull/${number}`,
65 baseRefName: "main",
66 headRefName,
67 state: "OPEN",
68 isCrossRepository: owner !== "acme",
69 updatedAt: "2026-01-02T00:00:00Z",
70 headRepository: { name: "web", nameWithOwner: `${owner}/web` },
71 headRepositoryOwner: { login: owner },
72});
73
74/**
75 * The provider over a mocked GitHubApi, git driver and process. `remotes` is what
76 * `git remote -v` prints; `git` records every driver call.
77 */
78function harness(input: {
79 readonly remotes: string;
80 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;
81 readonly localBranches?: ReadonlyArray<string>;
82 readonly resolved?: string;
83 /** Fails the local branch listing, the first git read a checkout makes. */
84 readonly gitFailure?: unknown;
85}) {
86 const git: Array<readonly [string, unknown]> = [];
87 const record =
88 <A>(name: string, value: A) =>
89 (args: unknown) =>
90 Effect.sync(() => {
91 git.push([name, args]);
92 return value;
93 });
94 const hostGit: Partial<SourceControlHost.SourceControlHost["Service"]["git"]> = {
95 execute: (args) =>
96 Effect.sync(() => {
97 git.push(["execute", args.args]);
98 return processOutput("");
99 }),
100 resolvePrimaryRemoteName: () => Effect.succeed("origin"),
101 readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"),
102 ensureRemote: (args) =>
103 Effect.sync(() => {
104 git.push(["ensureRemote", args]);
105 return args.preferredName;
106 }),
107 fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args),
108 setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args),
109 switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never,
110 listLocalBranchNames: () =>
111 input.gitFailure === undefined
112 ? Effect.succeed([...(input.localBranches ?? [])])
113 : Effect.fail(input.gitFailure as never),
114 resolveCommit: () => Effect.succeed({ commitSha: "abc123" }),
115 };
116 const host = TestSourceControlHost.layer({
117 git: hostGit,
118 process: {
119 run: (args) =>
120 Effect.succeed(
121 args.args[0] === "remote"
122 ? processOutput(input.remotes)
123 : processOutput(input.resolved ?? "", input.resolved ? 0 : 1),
124 ),
125 },
126 });
127 const layer = Layer.mergeAll(
128 host,
129 Layer.mock(GitHubApi.GitHubApi)(input.api),
130 NodeServices.layer,
131 );
132 return { layer, git };
133}
134
135/** The provider context for a checkout whose remote is on `host`. */
136/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */
137const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
138 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
139
140const githubContext = (host: string) => ({
141 provider: {
142 kind: SourceControlProviderKind.make("github"),
143 name: "GitHub",
144 baseUrl: `https://${host}`,
145 },
146 remoteName: "origin",
147 remoteUrl: `git@${host}:acme/web.git`,
148});
149
150describe("GitHubSourceControlProvider repository resolution", () => {
151 it.effect("reads the repository gh would pick from the remotes", () => {
152 const paths: string[] = [];
153 const { layer } = harness({
154 remotes: remotesOutput(
155 ["origin", "git@github.com:me/web.git"],
156 ["upstream", "https://github.com/acme/web.git"],
157 ),
158 api: {
159 rest: (input) =>
160 Effect.sync(() => {
161 paths.push(`${input.host} ${input.path}`);
162 return restResponse({
163 full_name: "acme/web",
164 html_url: "https://github.com/acme/web",
165 ssh_url: "git@github.com:acme/web.git",
166 default_branch: "trunk",
167 });
168 }),
169 },
170 });
171 return Effect.gen(function* () {
172 const gh = yield* GitHubSourceControlProvider.make;
173 assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk");
174 assert.deepStrictEqual(paths, ["github.com repos/acme/web"]);
175 }).pipe(Effect.provide(layer));
176 });
177
178 it.effect("reads an SSH alias remote through github.com", () => {
179 const hosts: string[] = [];
180 const { layer } = harness({
181 remotes: remotesOutput(["origin", "git@github:acme/web.git"]),
182 api: {
183 rest: (input) =>
184 Effect.sync(() => {
185 hosts.push(`${input.host} ${input.path}`);
186 return restResponse({
187 full_name: "acme/web",
188 html_url: "https://github.com/acme/web",
189 ssh_url: "git@github.com:acme/web.git",
190 default_branch: "main",
191 });
192 }),
193 },
194 });
195 return Effect.gen(function* () {
196 const gh = yield* GitHubSourceControlProvider.make;
197 yield* gh.getDefaultBranch({ cwd: "/repo" });
198 // A provider's host hint for the same alias (`github` here) resolves the same way.
199 yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") });
200 assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]);
201 }).pipe(Effect.provide(layer));
202 });
203
204 it.effect("names a missing repository rather than a pull request", () => {
205 const { layer } = harness({
206 remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]),
207 api: {
208 rest: (input) =>
209 Effect.fail(
210 new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }),
211 ),
212 },
213 });
214 return Effect.gen(function* () {
215 const gh = yield* GitHubSourceControlProvider.make;
216 const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip);
217 assert.include(error.detail, "Repository not found");
218 }).pipe(Effect.provide(layer));
219 });
220
221 it.effect("fails clearly when no remote is on GitHub", () => {
222 const { layer } = harness({
223 remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]),
224 api: {},
225 });
226 return Effect.gen(function* () {
227 const gh = yield* GitHubSourceControlProvider.make;
228 const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip);
229 assert.strictEqual(error._tag, "SourceControlProviderError");
230 assert.include(error.detail, "No GitHub repository");
231 }).pipe(Effect.provide(layer));
232 });
233});
234
235describe("GitHubSourceControlProvider change request lookups by head", () => {
236 const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]);
237
238 it.effect("reads a background sweep's staggered heads in one GraphQL document", () => {
239 const documents: Array<GitHubApi.GitHubGraphQlInput> = [];
240 const { layer } = harness({
241 remotes,
242 api: {
243 graphql: (input) =>
244 Effect.sync(() => {
245 documents.push(input);
246 return encodeJson({
247 data: {
248 repository: {
249 h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] },
250 h1: { nodes: [] },
251 },
252 },
253 });
254 }),
255 },
256 });
257 return Effect.gen(function* () {
258 const gh = yield* GitHubSourceControlProvider.make;
259 const lookup = (headSelector: string) =>
260 gh
261 .listChangeRequests({
262 cwd: "/repo",
263 headSelector,
264 state: "all",
265 limit: 100,
266 context: githubContext("github.com"),
267 })
268 .pipe(Effect.forkChild);
269 // Each branch's own git reads come first, so a sweep's lookups arrive spread out.
270 const firstLookup = yield* lookup("feature/a");
271 yield* TestClock.adjust("200 millis");
272 const secondLookup = yield* lookup("feature/b");
273 yield* TestClock.adjust("300 millis");
274 const first = yield* Fiber.join(firstLookup);
275 const second = yield* Fiber.join(secondLookup);
276 assert.deepStrictEqual(
277 first?.map((pr) => pr.number),
278 [7],
279 );
280 assert.strictEqual(first?.[0]?.headSha, "a".repeat(40));
281 assert.deepStrictEqual(second, []);
282 assert.strictEqual(documents.length, 1);
283 assert.include(documents[0]!.query, "headRefOid");
284 assert.deepStrictEqual(documents[0]!.variables, {
285 owner: "acme",
286 name: "web",
287 h0: "feature/a",
288 s0: ["OPEN", "CLOSED", "MERGED"],
289 h1: "feature/b",
290 s1: ["OPEN", "CLOSED", "MERGED"],
291 });
292 }).pipe(Effect.provide(layer));
293 });
294
295 it.effect("caps a background document at twenty-five heads", () => {
296 const headCounts: Array<number> = [];
297 const { layer } = harness({
298 remotes,
299 api: {
300 graphql: (input) =>
301 Effect.sync(() => {
302 const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key));
303 headCounts.push(heads.length);
304 return encodeJson({
305 data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) },
306 });
307 }),
308 },
309 });
310 return Effect.gen(function* () {
311 const gh = yield* GitHubSourceControlProvider.make;
312 const lookups = yield* Effect.all(
313 Array.from({ length: 26 }, (_, index) =>
314 gh.listChangeRequests({
315 cwd: "/repo",
316 headSelector: `feature/${index}`,
317 state: "all",
318 limit: 100,
319 context: githubContext("github.com"),
320 }),
321 ),
322 { concurrency: "unbounded" },
323 ).pipe(Effect.forkChild);
324 yield* TestClock.adjust("500 millis");
325 yield* Fiber.join(lookups);
326 assert.deepStrictEqual(
327 headCounts.toSorted((a, b) => a - b),
328 [1, 25],
329 );
330 }).pipe(Effect.provide(layer));
331 });
332
333 it.effect("matches an owner:branch selector on the head owner", () => {
334 const { layer } = harness({
335 remotes,
336 api: {
337 graphql: (input) =>
338 Effect.succeed(
339 encodeJson({
340 data: {
341 repository: {
342 h0: {
343 nodes:
344 input.variables?.h0 === "main"
345 ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")]
346 : [],
347 },
348 },
349 },
350 }),
351 ),
352 },
353 });
354 return Effect.gen(function* () {
355 const gh = yield* GitHubSourceControlProvider.make;
356 const open = yield* gh
357 .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 })
358 .pipe(Effect.forkChild);
359 yield* TestClock.adjust("50 millis");
360 assert.deepStrictEqual(
361 (yield* Fiber.join(open)).map((pr) => pr.number),
362 [8],
363 );
364 }).pipe(Effect.provide(layer));
365 });
366
367 it.effect("maps API failures onto the errors callers handle", () => {
368 const { layer } = harness({
369 remotes,
370 api: {
371 graphql: (input) =>
372 Effect.fail(
373 input.variables?.h0 === "missing"
374 ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" })
375 : new GitHubApi.GitHubApiRateLimitError({
376 host: "github.com",
377 operation: "x",
378 retryAt: 123,
379 }),
380 ),
381 },
382 });
383 return Effect.gen(function* () {
384 const gh = yield* GitHubSourceControlProvider.make;
385 const read = (headSelector: string) =>
386 gh
387 .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 })
388 .pipe(Effect.flip, Effect.forkChild);
389 const missing = yield* read("missing");
390 yield* TestClock.adjust("500 millis");
391 assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server");
392 const limited = yield* read("limited");
393 yield* TestClock.adjust("500 millis");
394 const error = yield* Fiber.join(limited);
395 assert.include(error.detail, "rate limit exceeded");
396 assert.propertyVal(error.cause, "retryAt", 123);
397 }).pipe(Effect.provide(layer));
398 });
399});
400
401describe("GitHubSourceControlProvider.getChangeRequest", () => {
402 it.effect("reads a pull request by number, and by URL on its own repository", () => {
403 const variables: Array<unknown> = [];
404 const { layer } = harness({
405 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
406 api: {
407 graphql: (input) =>
408 Effect.sync(() => {
409 variables.push(input.variables);
410 return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } });
411 }),
412 },
413 });
414 return Effect.gen(function* () {
415 const gh = yield* GitHubSourceControlProvider.make;
416 assert.strictEqual(
417 (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number,
418 42,
419 );
420 yield* gh.getChangeRequest({
421 cwd: "/repo",
422 reference: "https://github.com/other/thing/pull/42",
423 });
424 assert.deepStrictEqual(variables, [
425 { owner: "acme", name: "web", number: 42 },
426 { owner: "other", name: "thing", number: 42 },
427 ]);
428 }).pipe(Effect.provide(layer));
429 });
430
431 it.effect("fails a missing pull request as not found", () => {
432 const { layer } = harness({
433 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
434 api: {
435 graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })),
436 },
437 });
438 return Effect.gen(function* () {
439 const gh = yield* GitHubSourceControlProvider.make;
440 const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip);
441 assert.include(error.detail, "Pull request not found");
442 }).pipe(Effect.provide(layer));
443 });
444});
445
446describe("GitHubSourceControlProvider writes", () => {
447 it.effect("creates a cross-repository pull request with an owner:branch head", () => {
448 const requests: Array<GitHubApi.GitHubRestInput> = [];
449 const { layer } = harness({
450 remotes: remotesOutput(
451 ["origin", "git@github.com:me/web.git"],
452 ["upstream", "git@github.com:acme/web.git"],
453 ),
454 api: {
455 rest: (input) =>
456 Effect.sync(() => {
457 requests.push(input);
458 return restResponse({ number: 1 }, 201);
459 }),
460 },
461 });
462 return Effect.gen(function* () {
463 const fs = yield* FileSystem.FileSystem;
464 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
465 yield* fs.writeFileString(bodyFile, "Body");
466 const gh = yield* GitHubSourceControlProvider.make;
467 yield* gh.createChangeRequest({
468 cwd: "/repo",
469 baseRefName: "main",
470 headSelector: "me:feature",
471 title: "Title",
472 bodyFile,
473 });
474 assert.strictEqual(requests[0]!.method, "POST");
475 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");
476 // A user's own write, so the background's reserve is not held against it.
477 assert.strictEqual(requests[0]!.allowReserve, true);
478 assert.deepStrictEqual(requests[0]!.body, {
479 base: "main",
480 head: "me:feature",
481 title: "Title",
482 body: "Body",
483 maintainer_can_modify: true,
484 });
485 }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped);
486 });
487
488 it.effect("creates a repository under an organization the viewer is not", () => {
489 const requests: Array<string> = [];
490 const { layer } = harness({
491 remotes: "",
492 api: {
493 rest: (input) =>
494 Effect.sync(() => {
495 requests.push(`${input.method ?? "GET"} ${input.path}`);
496 return input.path === "user"
497 ? restResponse({ login: "me" })
498 : restResponse({
499 full_name: "acme/new",
500 html_url: "https://github.com/acme/new",
501 ssh_url: "git@github.com:acme/new.git",
502 });
503 }),
504 },
505 });
506 return Effect.gen(function* () {
507 const gh = yield* GitHubSourceControlProvider.make;
508 const urls = yield* gh.createRepository({
509 cwd: "/repo",
510 repository: "acme/new",
511 visibility: "private",
512 });
513 assert.deepStrictEqual(urls, {
514 nameWithOwner: "acme/new",
515 url: "https://github.com/acme/new",
516 sshUrl: "git@github.com:acme/new.git",
517 });
518 assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]);
519 }).pipe(Effect.provide(layer));
520 });
521
522 it.effect("looks up a bare repository name under the signed-in account on that host", () => {
523 const requests: Array<string> = [];
524 return Effect.gen(function* () {
525 const gh = yield* makeProvider({
526 rest: (input) =>
527 Effect.sync(() => {
528 requests.push(`${input.host} ${input.method ?? "GET"} ${input.path}`);
529 return input.path === "user"
530 ? restResponse({ login: "me" })
531 : restResponse({
532 full_name: "me/notes",
533 html_url: `https://${input.host}/me/notes`,
534 ssh_url: `git@${input.host}:me/notes.git`,
535 });
536 }),
537 });
538
539 const urls = yield* gh.getRepositoryCloneUrls({
540 cwd: "/repo",
541 context: githubContext("code.example.test"),
542 repository: " notes.git ",
543 });
544
545 assert.deepStrictEqual(urls, {
546 nameWithOwner: "me/notes",
547 url: "https://code.example.test/me/notes",
548 sshUrl: "git@code.example.test:me/notes.git",
549 });
550 assert.deepStrictEqual(requests, [
551 "code.example.test GET user",
552 "code.example.test GET repos/me/notes",
553 ]);
554 // Malformed names are still refused before any request.
555 for (const repository of ["me/", "/notes", "a/b/c/d", "", "two words"]) {
556 const error = yield* Effect.flip(gh.getRepositoryCloneUrls({ cwd: "/repo", repository }));
557 assert.include(error.message, "Repositories are named owner/name.");
558 }
559 assert.strictEqual(requests.length, 2);
560 });
561 });
562});
563
564describe("GitHubSourceControlProvider.checkoutChangeRequest", () => {
565 const repository = (fullName: string, defaultBranch = "main") =>
566 restResponse({
567 full_name: fullName,
568 html_url: `https://github.com/${fullName}`,
569 ssh_url: `git@github.com:${fullName}.git`,
570 default_branch: defaultBranch,
571 });
572
573 it.effect("checks a same-repository pull request out from its head branch", () => {
574 const { layer, git } = harness({
575 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
576 api: {
577 graphql: () =>
578 Effect.succeed(
579 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
580 ),
581 },
582 });
583 return Effect.gen(function* () {
584 const gh = yield* GitHubSourceControlProvider.make;
585 yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" });
586 assert.deepStrictEqual(git, [
587 [
588 "fetchRemoteTrackingBranch",
589 { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" },
590 ],
591 ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]],
592 ["switchRef", { cwd: "/repo", refName: "feature/x" }],
593 [
594 "setBranchUpstream",
595 { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" },
596 ],
597 ]);
598 }).pipe(Effect.provide(layer));
599 });
600
601 it.effect("refuses a fork checkout when the base's default branch cannot be read", () => {
602 const { layer, git } = harness({
603 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
604 localBranches: ["main"],
605 api: {
606 graphql: () =>
607 Effect.succeed(
608 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
609 ),
610 rest: () =>
611 Effect.fail(
612 new GitHubApi.GitHubApiRequestError({
613 host: "github.com",
614 operation: "x",
615 cause: "offline",
616 }),
617 ),
618 },
619 });
620 return Effect.gen(function* () {
621 const gh = yield* GitHubSourceControlProvider.make;
622 yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }));
623 // Nothing touched the local branches: `main` must not be reset to the fork's commit.
624 assert.deepStrictEqual(git, []);
625 }).pipe(Effect.provide(layer));
626 });
627
628 it.effect("adds a remote for a fork and names a default-branch head after its owner", () => {
629 const { layer, git } = harness({
630 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
631 localBranches: ["someone/main"],
632 api: {
633 graphql: () =>
634 Effect.succeed(
635 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
636 ),
637 rest: (input) =>
638 Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")),
639 },
640 });
641 return Effect.gen(function* () {
642 const gh = yield* GitHubSourceControlProvider.make;
643 yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true });
644 assert.deepStrictEqual(git, [
645 [
646 "ensureRemote",
647 { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" },
648 ],
649 [
650 "fetchRemoteTrackingBranch",
651 { cwd: "/repo", remoteName: "someone", remoteBranch: "main" },
652 ],
653 ["switchRef", { cwd: "/repo", refName: "someone/main" }],
654 ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]],
655 [
656 "setBranchUpstream",
657 { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" },
658 ],
659 ]);
660 }).pipe(Effect.provide(layer));
661 });
662
663 it.effect("keeps git's own output out of what a failed checkout reports", () => {
664 const { layer } = harness({
665 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
666 gitFailure: new Error("fatal: /home/me/secret-path: permission denied"),
667 api: {
668 graphql: () =>
669 Effect.succeed(
670 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
671 ),
672 },
673 });
674 return Effect.gen(function* () {
675 const gh = yield* GitHubSourceControlProvider.make;
676 const error = yield* gh
677 .checkoutChangeRequest({ cwd: "/repo", reference: "5" })
678 .pipe(Effect.flip);
679 assert.strictEqual(error.detail, "The pull request could not be checked out with git.");
680 assert.notInclude(error.message, "secret-path");
681 }).pipe(Effect.provide(layer));
682 });
683
684 it("names the local branch the way gh pr checkout does", () => {
685 const name = (headRefName: string, isCrossRepository: boolean) =>
686 GitHubSourceControlProvider.pullRequestCheckoutBranchName({
687 headRefName,
688 headOwner: "someone",
689 isCrossRepository,
690 defaultBranch: "main",
691 });
692 assert.strictEqual(name("main", true), "someone/main");
693 assert.strictEqual(name("feature", true), "feature");
694 assert.strictEqual(name("main", false), "main");
695 });
696});
697
698it("accepts active authenticated GitHub accounts when another account fails", () => {
699 const auth = GitHubSourceControlProvider.discovery.parseAuth(
700 processResult(
701 JSON.stringify({
702 hosts: {
703 "github.com": [
704 {
705 state: "success",
706 active: true,
707 host: "github.com",
708 login: "active-user",
709 tokenSource: "keyring",
710 gitProtocol: "ssh",
711 },
712 {
713 state: "error",
714 active: false,
715 host: "github.com",
716 login: "stale-user",
717 tokenSource: "keyring",
718 gitProtocol: "ssh",
719 error: "The token in keyring is invalid.",
720 },
721 ],
722 },
723 }),
724 ),
725 );
726
727 assert.deepStrictEqual(
728 {
729 status: auth.status,
730 account: auth.account,
731 host: auth.host,
732 },
733 {
734 status: "authenticated",
735 account: Option.some("active-user"),
736 host: Option.some("github.com"),
737 },
738 );
739});
740
741it("parses GitHub auth JSON from stdout when stderr has warnings", () => {
742 const auth = GitHubSourceControlProvider.discovery.parseAuth(
743 processResult(
744 JSON.stringify({
745 hosts: {
746 "github.com": [
747 {
748 state: "success",
749 active: true,
750 host: "github.com",
751 login: "active-user",
752 tokenSource: "keyring",
753 gitProtocol: "ssh",
754 },
755 ],
756 },
757 }),
758 { stderr: "warning: ignored diagnostic from gh\n" },
759 ),
760 );
761
762 assert.deepStrictEqual(
763 {
764 status: auth.status,
765 account: auth.account,
766 host: auth.host,
767 },
768 {
769 status: "authenticated",
770 account: Option.some("active-user"),
771 host: Option.some("github.com"),
772 },
773 );
774});
775
776it("parses GitHub auth status accounts by host and active state", () => {
777 assert.deepStrictEqual(
778 parseGitHubAuthStatus(
779 JSON.stringify({
780 hosts: {
781 "github.com": [
782 {
783 state: "success",
784 active: true,
785 host: "github.com",
786 login: "active-user",
787 tokenSource: "keyring",
788 gitProtocol: "ssh",
789 },
790 {
791 state: "error",
792 active: false,
793 host: "github.com",
794 login: "stale-user",
795 tokenSource: "keyring",
796 gitProtocol: "ssh",
797 },
798 ],
799 "github.example.test": [
800 {
801 state: "success",
802 active: false,
803 host: "github.example.test",
804 login: "enterprise-user",
805 tokenSource: "keyring",
806 gitProtocol: "ssh",
807 },
808 ],
809 },
810 }),
811 ).accounts,
812 [
813 {
814 host: "github.com",
815 account: "active-user",
816 authenticated: true,
817 active: true,
818 error: null,
819 environmentVariable: null,
820 },
821 {
822 host: "github.com",
823 account: "stale-user",
824 authenticated: false,
825 active: false,
826 error: null,
827 environmentVariable: null,
828 },
829 {
830 host: "github.example.test",
831 account: "enterprise-user",
832 authenticated: true,
833 active: false,
834 error: null,
835 environmentVariable: null,
836 },
837 ],
838 );
839});
840
841it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => {
842 const auth = GitHubSourceControlProvider.discovery.parseAuth(
843 processResult(
844 JSON.stringify({
845 hosts: {
846 "github.com": [
847 {
848 state: "error",
849 active: true,
850 host: "github.com",
851 login: "stale-user",
852 tokenSource: "keyring",
853 gitProtocol: "ssh",
854 error: "The token in keyring is invalid.",
855 },
856 ],
857 },
858 }),
859 ),
860 );
861
862 assert.deepStrictEqual(
863 {
864 status: auth.status,
865 host: auth.host,
866 detail: auth.detail,
867 },
868 {
869 status: "unauthenticated",
870 host: Option.some("github.com"),
871 detail: Option.some("The token in keyring is invalid."),
872 },
873 );
874});
875
876it("reports an update hint instead of unauthenticated when gh predates --json", () => {
877 const auth = GitHubSourceControlProvider.discovery.parseAuth(
878 processResult("", {
879 stderr: "unknown flag: --json\n\nUsage: gh auth status [flags]\n",
880 exitCode: ChildProcessSpawner.ExitCode(1),
881 }),
882 );
883
884 assert.strictEqual(auth.status, "unknown");
885 assert.match(
886 Option.getOrElse(auth.detail, () => ""),
887 /2\.81\.0/,
888 );
889});
890
891it.effect.each(["pull", "issues"])(
892 "resolves %s subjects on the linked host without using the checkout",
893 (kind) =>
894 Effect.gen(function* () {
895 const provider = yield* makeProvider({
896 rest: (input) => {
897 assert.strictEqual(input.host, "github.com");
898 assert.strictEqual(input.path, "repos/owner/repo/issues/42");
899 return Effect.succeed(
900 restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }),
901 );
902 },
903 });
904 const lookup = provider.resolveLink?.({
905 cwd: "/unrelated",
906 url: new URL(`https://github.com/owner/repo/${kind}/42`),
907 });
908 assert.ok(lookup);
909 assert.deepStrictEqual(yield* lookup, {
910 title: "Pairing expiry",
911 body: "Preserve remote access",
912 });
913 assert.strictEqual(
914 provider.resolveLink?.({
915 cwd: "/unrelated",
916 url: new URL("https://github.com/owner/repo"),
917 }),
918 undefined,
919 );
920 }),
921);
922
923it.effect.each(["read", "decode"] as const)(
924 "retains the %s failure without exposing its raw contents",
925 (stage) =>
926 Effect.gen(function* () {
927 const cause = new GitHubApi.GitHubApiResponseError({
928 host: "github.com",
929 operation: "resolveLink",
930 status: 500,
931 });
932 const provider = yield* makeProvider({
933 rest: () =>
934 stage === "read"
935 ? Effect.fail(cause)
936 : Effect.succeed({ ...restResponse(undefined), body: "private response text" }),
937 });
938 const lookup = provider.resolveLink?.({
939 cwd: "/repo",
940 url: new URL("https://github.com/owner/repo/issues/42"),
941 });
942 assert.ok(lookup);
943 const error = yield* Effect.flip(lookup);
944 assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode");
945 assert.strictEqual(error.detail, "The linked subject could not be read.");
946 assert.notInclude(error.message, "private response text");
947 if (stage === "read") assert.strictEqual(error.cause, cause);
948 else assert.propertyVal(error.cause, "_tag", "SchemaError");
949 }),
950);
951
952const multiAccountStatus = (extra: ReadonlyArray<Record<string, unknown>> = []) =>
953 processResult(
954 JSON.stringify({
955 hosts: {
956 "github.com": [
957 { state: "success", active: true, host: "github.com", login: "personal" },
958 { state: "success", active: false, host: "github.com", login: "work" },
959 ...extra,
960 ],
961 "ghe.acme.test": [
962 { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" },
963 ],
964 },
965 }),
966 );
967
968it("reports every gh login and leads with the account Settings pin", () => {
969 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
970 hosts: { "github.com": { account: "work", enabled: true } },
971 tokens: {},
972 });
973 assert.deepStrictEqual(auth.account, Option.some("work"));
974 assert.deepStrictEqual(auth.accounts, [
975 { host: "github.com", account: "personal", active: true, authenticated: true },
976 { host: "github.com", account: "work", active: false, authenticated: true },
977 { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" },
978 ]);
979});
980
981it("falls back to gh's active login when the pinned account is gone", () => {
982 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
983 hosts: { "github.com": { account: "former-job", enabled: true } },
984 tokens: {},
985 });
986 assert.deepStrictEqual(auth.account, Option.some("personal"));
987});
988
989it("reports unauthenticated when Settings turn off every signed-in host", () => {
990 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
991 hosts: { "github.com": { enabled: false } },
992 tokens: {},
993 });
994 assert.strictEqual(auth.status, "unauthenticated");
995 assert.deepStrictEqual(
996 auth.detail,
997 Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."),
998 );
999});
1000
1001it("names the environment token that overrides the Settings choice", () => {
1002 const auth = GitHubSourceControlProvider.parseGitHubAuth(
1003 multiAccountStatus([
1004 {
1005 state: "success",
1006 active: false,
1007 host: "github.com",
1008 login: "bot",
1009 tokenSource: "GH_TOKEN",
1010 },
1011 ]),
1012 { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} },
1013 );
1014 assert.deepStrictEqual(auth.account, Option.some("bot"));
1015 assert.deepStrictEqual(
1016 auth.detail,
1017 Option.some(
1018 "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.",
1019 ),
1020 );
1021 assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN");
1022});
1023
1024it.live.each([
1025 "git@code.example.test:team/project.git",
1026 "ssh://git@code.example.test/team/project.git",
1027 "https://code.example.test/team/project.git",
1028])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => {
1029 const graphql: GitHubApi.GitHubGraphQlInput[] = [];
1030 const rest: GitHubApi.GitHubRestInput[] = [];
1031 const api = {
1032 graphql: (input: GitHubApi.GitHubGraphQlInput) =>
1033 Effect.sync(() => {
1034 graphql.push(input);
1035 const pr = {
1036 ...node(42, "feature", "team"),
1037 url: "https://code.example.test/team/project/pull/42",
1038 isCrossRepository: false,
1039 headRepository: { name: "project", nameWithOwner: "team/project" },
1040 };
1041 return encodeJson({
1042 data: {
1043 repository:
1044 input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } },
1045 },
1046 });
1047 }),
1048 rest: (input: GitHubApi.GitHubRestInput) =>
1049 Effect.sync(() => {
1050 rest.push(input);
1051 return restResponse({
1052 full_name: "team/project",
1053 html_url: `https://${input.host}/team/project`,
1054 ssh_url: `git@${input.host}:team/project.git`,
1055 default_branch: "main",
1056 });
1057 }),
1058 };
1059 const { layer, git } = harness({
1060 remotes: remotesOutput(
1061 ["origin", "git@github.com:team/project.git"],
1062 ["enterprise", remoteUrl],
1063 ),
1064 api,
1065 });
1066 return Effect.gen(function* () {
1067 const provider = yield* GitHubSourceControlProvider.make;
1068 const fs = yield* FileSystem.FileSystem;
1069 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
1070 yield* fs.writeFileString(bodyFile, "Enterprise PR");
1071 const input = {
1072 cwd: "/repo",
1073 context: {
1074 provider: {
1075 kind: SourceControlProviderKind.make("github"),
1076 name: "GitHub Self-Hosted",
1077 baseUrl: "https://code.example.test",
1078 },
1079 remoteName: "enterprise",
1080 remoteUrl,
1081 },
1082 };
1083 for (const state of ["open", "closed"] as const) {
1084 const prs = yield* provider.listChangeRequests({
1085 ...input,
1086 state,
1087 headSelector: "feature",
1088 });
1089 assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42");
1090 }
1091 yield* provider.getChangeRequest({ ...input, reference: "42" });
1092 yield* provider.getChangeRequest({ ...input, reference: "feature" });
1093 yield* provider.createChangeRequest({
1094 ...input,
1095 baseRefName: "main",
1096 headSelector: "feature",
1097 title: "PR",
1098 bodyFile,
1099 });
1100 assert.strictEqual(yield* provider.getDefaultBranch(input), "main");
1101 yield* provider.checkoutChangeRequest({ ...input, reference: "42" });
1102 yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" });
1103 yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" });
1104 assert.strictEqual(graphql.length, 5);
1105 for (const request of graphql) {
1106 assert.strictEqual(request.host, "code.example.test");
1107 assert.strictEqual(request.variables?.owner, "team");
1108 assert.strictEqual(request.variables?.name, "project");
1109 }
1110 assert.deepStrictEqual(
1111 rest.map(({ host, path }) => [host, path]),
1112 [
1113 ["code.example.test", "repos/team/project/pulls"],
1114 ["code.example.test", "repos/team/project"],
1115 ["code.example.test", "repos/team/other"],
1116 ["github.com", "repos/team/explicit"],
1117 ],
1118 );
1119 assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], {
1120 cwd: "/repo",
1121 remoteName: "enterprise",
1122 remoteBranch: "feature",
1123 });
1124 }).pipe(Effect.provide(layer), Effect.provideService(HostProcess.Environment, {}), Effect.scoped);
1125});
1126
1127it.effect.each([
1128 { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" },
1129 { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" },
1130 {
1131 host: "github.com",
1132 resolved: "remote.origin.gh-resolved base",
1133 envRepository: "",
1134 expected: "me/web",
1135 },
1136 {
1137 host: "code.example.test",
1138 resolved: "remote.origin.gh-resolved base",
1139 envRepository: "",
1140 expected: "me/web",
1141 },
1142 {
1143 host: "github.com",
1144 resolved: "remote.upstream.gh-resolved acme/other",
1145 envRepository: "",
1146 expected: "acme/other",
1147 },
1148 {
1149 host: "code.example.test",
1150 resolved: "remote.upstream.gh-resolved acme/other",
1151 envRepository: "",
1152 expected: "acme/other",
1153 },
1154 { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" },
1155 {
1156 host: "code.example.test",
1157 resolved: "",
1158 envRepository: "chosen/repo",
1159 expected: "chosen/repo",
1160 },
1161])(
1162 "preserves repository precedence on $host: $resolved $envRepository",
1163 ({ host, resolved, envRepository, expected }) => {
1164 const targets: string[] = [];
1165 const { layer } = harness({
1166 remotes: remotesOutput(
1167 ["origin", `git@${host}:me/web.git`],
1168 ["upstream", `git@${host}:acme/web.git`],
1169 ),
1170 resolved,
1171 api: {
1172 rest: (input) =>
1173 Effect.sync(() => {
1174 targets.push(`${input.host}/${input.path}`);
1175 return restResponse({
1176 full_name: expected,
1177 html_url: `https://${host}/${expected}`,
1178 ssh_url: `git@${host}:${expected}.git`,
1179 default_branch: "main",
1180 });
1181 }),
1182 },
1183 });
1184 return Effect.gen(function* () {
1185 const provider = yield* GitHubSourceControlProvider.make;
1186 const fs = yield* FileSystem.FileSystem;
1187 const bodyFile = yield* fs.makeTempFileScoped();
1188 yield* fs.writeFileString(bodyFile, "Body");
1189 const input = {
1190 cwd: "/repo",
1191 context: {
1192 provider: {
1193 kind: SourceControlProviderKind.make("github"),
1194 name: "GitHub",
1195 baseUrl: `https://${host}`,
1196 },
1197 remoteName: "origin",
1198 remoteUrl: `git@${host}:me/web.git`,
1199 },
1200 };
1201 yield* provider.getDefaultBranch(input);
1202 yield* provider.createChangeRequest({
1203 ...input,
1204 baseRefName: "main",
1205 headSelector: "me:feature",
1206 title: "PR",
1207 bodyFile,
1208 });
1209 assert.deepStrictEqual(targets, [
1210 `${host}/repos/${expected}`,
1211 `${host}/repos/${expected}/pulls`,
1212 ]);
1213 }).pipe(
1214 Effect.provide(layer),
1215 Effect.provideService(HostProcess.Environment, { GH_REPO: envRepository }),
1216 Effect.scoped,
1217 );
1218 },
1219);