| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import { ChildProcessSpawner } from "effect/process"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | |
| 14 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 15 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 16 | import * as GitHubApi from "./GitHubApi.ts"; |
| 17 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 18 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 19 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 20 | |
| 21 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 22 | |
| 23 | const 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 | |
| 37 | const 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 | |
| 48 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => |
| 49 | entries |
| 50 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) |
| 51 | .join("\n"); |
| 52 | |
| 53 | const 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 | |
| 61 | const 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 | */ |
| 78 | function 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. */ |
| 137 | const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) => |
| 138 | GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); |
| 139 | |
| 140 | const 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 | |
| 150 | describe("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 | |
| 235 | describe("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 | |
| 401 | describe("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 | |
| 446 | describe("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 | |
| 564 | describe("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 | |
| 698 | it("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 | |
| 741 | it("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 | |
| 776 | it("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 | |
| 841 | it("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 | |
| 876 | it("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 | |
| 891 | it.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 | |
| 923 | it.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 | |
| 952 | const 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 | |
| 968 | it("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 | |
| 981 | it("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 | |
| 989 | it("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 | |
| 1001 | it("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 | |
| 1024 | it.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 | |
| 1127 | it.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 | ); |