| author | |
| committer | |
| log | 10ac2f2ba495508a2a2fd469e67eda5df73f0df1 |
| tree | c2ec7bdd5a8e82c799509c03a1d478337e4389e8 |
| parent | a3abb5266080c15b2a675d7f92b517b567c427e2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>15 files changed, 1039 insertions(+), 213 deletions(-)
apps/server/src/git/GitManager.test.ts+26| ... | @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; | ... | @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; |
| 13 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as PlatformError from "effect/PlatformError"; | 14 | import * as PlatformError from "effect/PlatformError"; |
| 15 | import * as References from "effect/References"; | 15 | import * as References from "effect/References"; |
| 16 | import * as Result from "effect/Result"; | ||
| 16 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 17 | import * as Scope from "effect/Scope"; | 18 | import * as Scope from "effect/Scope"; |
| 18 | import { TestClock } from "effect/testing"; | 19 | import { TestClock } from "effect/testing"; |
| ... | @@ -32,6 +33,7 @@ import { | ... | @@ -32,6 +33,7 @@ import { |
| 32 | TextGenerationError, | 33 | TextGenerationError, |
| 33 | } from "@t3tools/contracts"; | 34 | } from "@t3tools/contracts"; |
| 34 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 35 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 36 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; | ||
| 35 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 37 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 36 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 38 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 37 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 39 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| ... | @@ -510,6 +512,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -510,6 +512,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 510 | return { | 512 | return { |
| 511 | service: { | 513 | service: { |
| 512 | execute, | 514 | execute, |
| 515 | // The fake answers the CLI shape, so batched lookups read it the way the fallback does. | ||
| 516 | listPullRequestsByHead: (input) => | ||
| 517 | execute({ | ||
| 518 | cwd: input.cwd, | ||
| 519 | args: [ | ||
| 520 | "pr", | ||
| 521 | "list", | ||
| 522 | "--head", | ||
| 523 | input.headSelector, | ||
| 524 | "--state", | ||
| 525 | input.state, | ||
| 526 | "--limit", | ||
| 527 | String(input.limit), | ||
| 528 | "--json", | ||
| 529 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 530 | ], | ||
| 531 | }).pipe( | ||
| 532 | Effect.map((result) => { | ||
| 533 | const raw = result.stdout.trim(); | ||
| 534 | if (raw.length === 0) return []; | ||
| 535 | const decoded = decodeGitHubPullRequestListJson(raw); | ||
| 536 | return Result.isSuccess(decoded) ? decoded.success : []; | ||
| 537 | }), | ||
| 538 | ), | ||
| 513 | listOpenPullRequests: (input) => | 539 | listOpenPullRequests: (input) => |
| 514 | execute({ | 540 | execute({ |
| 515 | cwd: input.cwd, | 541 | cwd: input.cwd, |
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+82| ... | @@ -754,6 +754,88 @@ describe("PullRequestSyncReactor", () => { | ... | @@ -754,6 +754,88 @@ describe("PullRequestSyncReactor", () => { |
| 754 | ), | 754 | ), |
| 755 | ); | 755 | ); |
| 756 | 756 | ||
| 757 | it.effect("reads a burst of requested links in one sweep", () => | ||
| 758 | Effect.scoped( | ||
| 759 | Effect.gen(function* () { | ||
| 760 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 761 | const reading = yield* Deferred.make<void>(); | ||
| 762 | const release = yield* Deferred.make<void>(); | ||
| 763 | const fixture = yield* makeHarness({ | ||
| 764 | snapshot: makeSnapshot([]), | ||
| 765 | summary: (input) => | ||
| 766 | Effect.gen(function* () { | ||
| 767 | if (input.number === 1) { | ||
| 768 | yield* Deferred.succeed(reading, undefined); | ||
| 769 | yield* Deferred.await(release); | ||
| 770 | } | ||
| 771 | return makeSummary(input); | ||
| 772 | }), | ||
| 773 | }); | ||
| 774 | yield* Effect.gen(function* () { | ||
| 775 | const reactor = yield* startAndSweep(fixture); | ||
| 776 | yield* Ref.set( | ||
| 777 | fixture.snapshots, | ||
| 778 | makeSnapshot( | ||
| 779 | [1, 2, 3, 4].map((number) => | ||
| 780 | makeThread(`thread-${number}`, { pullRequests: [makeLink(number)] }), | ||
| 781 | ), | ||
| 782 | ), | ||
| 783 | ); | ||
| 784 | const request = (number: number) => | ||
| 785 | reactor.requestSync({ host: "github.com", repository: "owner/repository", number }); | ||
| 786 | yield* request(1); | ||
| 787 | yield* Deferred.await(reading); | ||
| 788 | yield* Effect.forEach([2, 3, 4], request, { discard: true }); | ||
| 789 | yield* Deferred.succeed(release, undefined); | ||
| 790 | yield* reactor.drain; | ||
| 791 | // One sweep for the first link and one for the three that arrived while it read. | ||
| 792 | assert.strictEqual(yield* Queue.size(fixture.snapshotReads), 2); | ||
| 793 | assert.deepStrictEqual( | ||
| 794 | (yield* Ref.get(fixture.summaryCalls)).map((ref) => ref.number).toSorted(), | ||
| 795 | [1, 2, 3, 4], | ||
| 796 | ); | ||
| 797 | }).pipe(Effect.provide(fixture.layer)); | ||
| 798 | }), | ||
| 799 | ), | ||
| 800 | ); | ||
| 801 | |||
| 802 | it.effect("skips the stack read for a pull request the summary places in no stack", () => | ||
| 803 | Effect.scoped( | ||
| 804 | Effect.gen(function* () { | ||
| 805 | yield* TestClock.setTime(Date.parse(NOW)); | ||
| 806 | const fixture = yield* makeHarness({ | ||
| 807 | snapshot: makeSnapshot([ | ||
| 808 | makeThread("unstacked", { pullRequests: [makeLink(1)] }), | ||
| 809 | makeThread("stacked", { pullRequests: [makeLink(2)] }), | ||
| 810 | makeThread("unknown", { pullRequests: [makeLink(3)] }), | ||
| 811 | ]), | ||
| 812 | summary: (input) => | ||
| 813 | Effect.succeed( | ||
| 814 | makeSummary( | ||
| 815 | input, | ||
| 816 | input.number === 1 | ||
| 817 | ? { stack: null } | ||
| 818 | : input.number === 2 | ||
| 819 | ? { stack: { number: 9, position: 1, size: 2, base: "main" } } | ||
| 820 | : {}, | ||
| 821 | ), | ||
| 822 | ), | ||
| 823 | }); | ||
| 824 | yield* Effect.gen(function* () { | ||
| 825 | yield* startAndSweep(fixture); | ||
| 826 | assert.deepStrictEqual( | ||
| 827 | (yield* Ref.get(fixture.stackCalls)).map((ref) => ref.number).toSorted(), | ||
| 828 | [2, 3], | ||
| 829 | ); | ||
| 830 | assert.deepStrictEqual( | ||
| 831 | (yield* Ref.get(fixture.syncCommands)).map((command) => command.number).toSorted(), | ||
| 832 | [1, 2, 3], | ||
| 833 | ); | ||
| 834 | }).pipe(Effect.provide(fixture.layer)); | ||
| 835 | }), | ||
| 836 | ), | ||
| 837 | ); | ||
| 838 | |||
| 757 | it.effect("polls open pull requests on settled threads every fifteen minutes", () => | 839 | it.effect("polls open pull requests on settled threads every fifteen minutes", () => |
| 758 | Effect.scoped( | 840 | Effect.scoped( |
| 759 | Effect.gen(function* () { | 841 | Effect.gen(function* () { |
apps/server/src/orchestration/PullRequestSyncReactor.ts+41-23| ... | @@ -133,6 +133,9 @@ export const make = Effect.gen(function* () { | ... | @@ -133,6 +133,9 @@ export const make = Effect.gen(function* () { |
| 133 | const lastSyncedAt = new Map<string, number>(); | 133 | const lastSyncedAt = new Map<string, number>(); |
| 134 | const requested = new Map<string, number>(); | 134 | const requested = new Map<string, number>(); |
| 135 | let requestGeneration = 0; | 135 | let requestGeneration = 0; |
| 136 | // Requested keys wait in `requested` for one queued sweep, so a burst of links (an agent | ||
| 137 | // linking dozens of pull requests) is read together and shares the summary batches. | ||
| 138 | let requestedSweepQueued = false; | ||
| 136 | const retryStacks = new Set<string>(); | 139 | const retryStacks = new Set<string>(); |
| 137 | 140 | ||
| 138 | const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => { | 141 | const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => { |
| ... | @@ -151,7 +154,8 @@ export const make = Effect.gen(function* () { | ... | @@ -151,7 +154,8 @@ export const make = Effect.gen(function* () { |
| 151 | <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> => | 154 | <E>(cause: Cause.Cause<E>): Effect.Effect<void, E> => |
| 152 | Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); | 155 | Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); |
| 153 | 156 | ||
| 154 | const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) { | 157 | /** `requested` reads only keys asked for through `requestSync`; `all` is the periodic pass. */ |
| 158 | const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (scope: "all" | "requested") { | ||
| 155 | const threads = yield* snapshots.listThreadsWithPullRequests(); | 159 | const threads = yield* snapshots.listThreadsWithPullRequests(); |
| 156 | const now = yield* DateTime.now; | 160 | const now = yield* DateTime.now; |
| 157 | const nowMs = DateTime.toEpochMillis(now); | 161 | const nowMs = DateTime.toEpochMillis(now); |
| ... | @@ -250,22 +254,31 @@ export const make = Effect.gen(function* () { | ... | @@ -250,22 +254,31 @@ export const make = Effect.gen(function* () { |
| 250 | retryStacks.has(key) || | 254 | retryStacks.has(key) || |
| 251 | entries.some( | 255 | entries.some( |
| 252 | (entry) => | 256 | (entry) => |
| 253 | entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), | 257 | entry.link.snapshot === null || |
| 258 | !snapshotFieldsEqual(entry.link.snapshot, fields) || | ||
| 259 | (summary.stack !== undefined && | ||
| 260 | (entry.link.stack?.number ?? null) !== (summary.stack?.number ?? null)), | ||
| 254 | ); | 261 | ); |
| 255 | const fetchedStack = needsStack | 262 | // A summary that says the pull request is in no stack, for links that hold none, already |
| 256 | ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( | 263 | // answers what the stack read would. |
| 257 | Effect.map((stack) => ({ | 264 | const knownUnstacked = |
| 258 | stack: stack === null ? null : ({ kind: "native", ...stack } as const), | 265 | summary.stack === null && entries.every((entry) => entry.link.stack === null); |
| 259 | })), | 266 | const fetchedStack = !needsStack |
| 260 | Effect.catchCauseIf( | 267 | ? null |
| 261 | (cause) => !Cause.hasInterruptsOnly(cause), | 268 | : knownUnstacked |
| 262 | () => | 269 | ? { stack: null } |
| 263 | Effect.logWarning("pull request stack lookup failed", { | 270 | : yield* pullRequests.stack(ref, { includeDetails: false }).pipe( |
| 264 | key, | 271 | Effect.map((stack) => ({ |
| 265 | }).pipe(Effect.as(null)), | 272 | stack: stack === null ? null : ({ kind: "native", ...stack } as const), |
| 266 | ), | 273 | })), |
| 267 | ) | 274 | Effect.catchCauseIf( |
| 268 | : null; | 275 | (cause) => !Cause.hasInterruptsOnly(cause), |
| 276 | () => | ||
| 277 | Effect.logWarning("pull request stack lookup failed", { | ||
| 278 | key, | ||
| 279 | }).pipe(Effect.as(null)), | ||
| 280 | ), | ||
| 281 | ); | ||
| 269 | if (needsStack) { | 282 | if (needsStack) { |
| 270 | if (fetchedStack === null) { | 283 | if (fetchedStack === null) { |
| 271 | retryStacks.add(key); | 284 | retryStacks.add(key); |
| ... | @@ -296,7 +309,7 @@ export const make = Effect.gen(function* () { | ... | @@ -296,7 +309,7 @@ export const make = Effect.gen(function* () { |
| 296 | yield* Effect.forEach( | 309 | yield* Effect.forEach( |
| 297 | groups, | 310 | groups, |
| 298 | ([key, entries]) => | 311 | ([key, entries]) => |
| 299 | (requestedKey === undefined || requestedKey === key) && isDue(key, entries, nowMs) | 312 | (scope === "all" || requested.has(key)) && isDue(key, entries, nowMs) |
| 300 | ? syncGroup(key, entries).pipe( | 313 | ? syncGroup(key, entries).pipe( |
| 301 | Effect.catchCause(logSkipped("pull request sync skipped", { key })), | 314 | Effect.catchCause(logSkipped("pull request sync skipped", { key })), |
| 302 | ) | 315 | ) |
| ... | @@ -307,8 +320,12 @@ export const make = Effect.gen(function* () { | ... | @@ -307,8 +320,12 @@ export const make = Effect.gen(function* () { |
| 307 | ); | 320 | ); |
| 308 | }); | 321 | }); |
| 309 | 322 | ||
| 310 | const worker = yield* makeDrainableWorker((key: string | undefined) => | 323 | const worker = yield* makeDrainableWorker((scope: "all" | "requested") => |
| 311 | sweep(key).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), | 324 | Effect.suspend(() => { |
| 325 | // Requests from here on queue another sweep; the ones already recorded are read by this. | ||
| 326 | if (scope === "requested") requestedSweepQueued = false; | ||
| 327 | return sweep(scope); | ||
| 328 | }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), | ||
| 312 | ); | 329 | ); |
| 313 | 330 | ||
| 314 | const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( | 331 | const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( |
| ... | @@ -322,7 +339,7 @@ export const make = Effect.gen(function* () { | ... | @@ -322,7 +339,7 @@ export const make = Effect.gen(function* () { |
| 322 | ); | 339 | ); |
| 323 | yield* forkParked( | 340 | yield* forkParked( |
| 324 | Effect.gen(function* () { | 341 | Effect.gen(function* () { |
| 325 | yield* worker.enqueue(undefined); | 342 | yield* worker.enqueue("all"); |
| 326 | yield* worker.drain; | 343 | yield* worker.drain; |
| 327 | }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), | 344 | }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), |
| 328 | ); | 345 | ); |
| ... | @@ -330,9 +347,10 @@ export const make = Effect.gen(function* () { | ... | @@ -330,9 +347,10 @@ export const make = Effect.gen(function* () { |
| 330 | 347 | ||
| 331 | const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => | 348 | const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => |
| 332 | Effect.suspend(() => { | 349 | Effect.suspend(() => { |
| 333 | const syncKey = threadPullRequestKeyOf(key); | 350 | requested.set(threadPullRequestKeyOf(key), ++requestGeneration); |
| 334 | requested.set(syncKey, ++requestGeneration); | 351 | if (requestedSweepQueued) return Effect.void; |
| 335 | return worker.enqueue(syncKey); | 352 | requestedSweepQueued = true; |
| 353 | return worker.enqueue("requested"); | ||
| 336 | }); | 354 | }); |
| 337 | 355 | ||
| 338 | return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; | 356 | return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; |
apps/server/src/orchestration/ThreadPullRequestReactor.ts+3-1| ... | @@ -348,7 +348,9 @@ export const make = Effect.gen(function* () { | ... | @@ -348,7 +348,9 @@ export const make = Effect.gen(function* () { |
| 348 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), | 348 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), |
| 349 | ), | 349 | ), |
| 350 | ), | 350 | ), |
| 351 | { concurrency: 8, discard: true }, | 351 | // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one |
| 352 | // GraphQL document, instead of one `gh pr list` per branch. | ||
| 353 | { concurrency: 32, discard: true }, | ||
| 352 | ); | 354 | ); |
| 353 | }); | 355 | }); |
| 354 | 356 |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+6-1| ... | @@ -1821,7 +1821,12 @@ export const make = Effect.gen(function* () { | ... | @@ -1821,7 +1821,12 @@ export const make = Effect.gen(function* () { |
| 1821 | const batchable = entries.filter( | 1821 | const batchable = entries.filter( |
| 1822 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, | 1822 | (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, |
| 1823 | ); | 1823 | ); |
| 1824 | const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); | 1824 | // Stack membership rides along where GitHub serves stacks, so the background sync can skip |
| 1825 | // the REST stack read for pull requests that are in none. | ||
| 1826 | const query = buildPullRequestSummariesGraphQlQuery( | ||
| 1827 | batchable.map((entry) => entry.request), | ||
| 1828 | first.request.host === "github.com", | ||
| 1829 | ); | ||
| 1825 | const batched = | 1830 | const batched = |
| 1826 | query === null | 1831 | query === null |
| 1827 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) | 1832 | ? Effect.succeed(new Map<number, GitHubPullRequestSummary>()) |
apps/server/src/pullRequest/PullRequestProvider.ts+5| ... | @@ -116,6 +116,11 @@ export interface ProviderChangeRequestSummary { | ... | @@ -116,6 +116,11 @@ export interface ProviderChangeRequestSummary { |
| 116 | readonly reviewDecision?: PullRequestReviewDecision | null | undefined; | 116 | readonly reviewDecision?: PullRequestReviewDecision | null | undefined; |
| 117 | readonly checksState?: PullRequestChecksState | null | undefined; | 117 | readonly checksState?: PullRequestChecksState | null | undefined; |
| 118 | readonly mergeability?: PullRequestMergeability | undefined; | 118 | readonly mergeability?: PullRequestMergeability | undefined; |
| 119 | /** | ||
| 120 | * The host-native stack the pull request sits in, from the same read. Null when the host says | ||
| 121 | * it is in none; absent when the read did not ask. | ||
| 122 | */ | ||
| 123 | readonly stack?: PullRequestStackMembership | null | undefined; | ||
| 119 | } | 124 | } |
| 120 | 125 | ||
| 121 | /** One layer of a host-native stack, bottom to top order is the array's. */ | 126 | /** One layer of a host-native stack, bottom to top order is the array's. */ |
apps/server/src/pullRequest/PullRequestService.ts+1| ... | @@ -1596,6 +1596,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1596,6 +1596,7 @@ export const make = Effect.gen(function* () { |
| 1596 | ...(changeRequest.mergeability === undefined | 1596 | ...(changeRequest.mergeability === undefined |
| 1597 | ? {} | 1597 | ? {} |
| 1598 | : { mergeability: changeRequest.mergeability }), | 1598 | : { mergeability: changeRequest.mergeability }), |
| 1599 | ...(changeRequest.stack === undefined ? {} : { stack: changeRequest.stack }), | ||
| 1599 | })), | 1600 | })), |
| 1600 | ); | 1601 | ); |
| 1601 | }), | 1602 | }), |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+38| ... | @@ -1934,5 +1934,43 @@ describe("batched pull request summaries", () => { | ... | @@ -1934,5 +1934,43 @@ describe("batched pull request summaries", () => { |
| 1934 | mergeability: "unknown", | 1934 | mergeability: "unknown", |
| 1935 | additions: 0, | 1935 | additions: 0, |
| 1936 | }); | 1936 | }); |
| 1937 | // The document did not ask about stacks, so the summary does not claim an answer. | ||
| 1938 | expect(decoded.success.get(0)).not.toHaveProperty("stack"); | ||
| 1939 | }); | ||
| 1940 | |||
| 1941 | it("reads stack membership only where the document asked for it", () => { | ||
| 1942 | expect( | ||
| 1943 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true), | ||
| 1944 | ).toContain("stack { number size baseRefName } stackEntry { position }"); | ||
| 1945 | expect( | ||
| 1946 | buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]), | ||
| 1947 | ).not.toContain("stack {"); | ||
| 1948 | const pullRequest = (number: number, stack: Record<string, unknown>) => ({ | ||
| 1949 | pullRequest: { | ||
| 1950 | number, | ||
| 1951 | title: "Stacked", | ||
| 1952 | url: `https://github.com/acme/web/pull/${number}`, | ||
| 1953 | headRefName: `feat/${number}`, | ||
| 1954 | baseRefName: "main", | ||
| 1955 | state: "OPEN", | ||
| 1956 | updatedAt: "2026-08-24T00:00:00Z", | ||
| 1957 | ...stack, | ||
| 1958 | }, | ||
| 1959 | }); | ||
| 1960 | const decoded = expectSuccess( | ||
| 1961 | decodePullRequestSummariesJson( | ||
| 1962 | JSON.stringify({ | ||
| 1963 | data: { | ||
| 1964 | s0: pullRequest(7, { stack: null, stackEntry: null }), | ||
| 1965 | s1: pullRequest(8, { | ||
| 1966 | stack: { number: 3, size: 2, baseRefName: "main" }, | ||
| 1967 | stackEntry: { position: 2 }, | ||
| 1968 | }), | ||
| 1969 | }, | ||
| 1970 | }), | ||
| 1971 | ), | ||
| 1972 | ); | ||
| 1973 | expect(decoded.get(0)?.stack).toBeNull(); | ||
| 1974 | expect(decoded.get(1)?.stack).toEqual({ number: 3, size: 2, base: "main", position: 2 }); | ||
| 1937 | }); | 1975 | }); |
| 1938 | }); | 1976 | }); |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+7-1| ... | @@ -1852,6 +1852,8 @@ export function decodePullRequestStatsJson( | ... | @@ -1852,6 +1852,8 @@ export function decodePullRequestStatsJson( |
| 1852 | * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup | 1852 | * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup |
| 1853 | * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. | 1853 | * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. |
| 1854 | */ | 1854 | */ |
| 1855 | const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }"; | ||
| 1856 | |||
| 1855 | const PULL_REQUEST_SUMMARY_SELECTION = | 1857 | const PULL_REQUEST_SUMMARY_SELECTION = |
| 1856 | "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + | 1858 | "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + |
| 1857 | "updatedAt mergedAt closedAt headRefName baseRefName " + | 1859 | "updatedAt mergedAt closedAt headRefName baseRefName " + |
| ... | @@ -1865,6 +1867,7 @@ const PULL_REQUEST_SUMMARY_SELECTION = | ... | @@ -1865,6 +1867,7 @@ const PULL_REQUEST_SUMMARY_SELECTION = |
| 1865 | */ | 1867 | */ |
| 1866 | export function buildPullRequestSummariesGraphQlQuery( | 1868 | export function buildPullRequestSummariesGraphQlQuery( |
| 1867 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, | 1869 | changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, |
| 1870 | includeStacks = false, | ||
| 1868 | ): string | null { | 1871 | ): string | null { |
| 1869 | if (changeRequests.length === 0) return null; | 1872 | if (changeRequests.length === 0) return null; |
| 1870 | const selections: string[] = []; | 1873 | const selections: string[] = []; |
| ... | @@ -1874,7 +1877,7 @@ export function buildPullRequestSummariesGraphQlQuery( | ... | @@ -1874,7 +1877,7 @@ export function buildPullRequestSummariesGraphQlQuery( |
| 1874 | if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; | 1877 | if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; |
| 1875 | if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; | 1878 | if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; |
| 1876 | selections.push( | 1879 | selections.push( |
| 1877 | ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, | 1880 | ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`, |
| 1878 | ); | 1881 | ); |
| 1879 | } | 1882 | } |
| 1880 | return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; | 1883 | return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; |
| ... | @@ -1920,6 +1923,8 @@ export interface GitHubPullRequestSummary { | ... | @@ -1920,6 +1923,8 @@ export interface GitHubPullRequestSummary { |
| 1920 | readonly reviewDecision: PullRequestReviewDecision | null; | 1923 | readonly reviewDecision: PullRequestReviewDecision | null; |
| 1921 | readonly checksState: PullRequestChecksState | null; | 1924 | readonly checksState: PullRequestChecksState | null; |
| 1922 | readonly mergeability: PullRequestMergeability; | 1925 | readonly mergeability: PullRequestMergeability; |
| 1926 | /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ | ||
| 1927 | readonly stack?: PullRequestStackMembership | null; | ||
| 1923 | } | 1928 | } |
| 1924 | 1929 | ||
| 1925 | /** | 1930 | /** |
| ... | @@ -1965,6 +1970,7 @@ export function decodePullRequestSummariesJson( | ... | @@ -1965,6 +1970,7 @@ export function decodePullRequestSummariesJson( |
| 1965 | }), | 1970 | }), |
| 1966 | ), | 1971 | ), |
| 1967 | mergeability: toMergeability(pr.mergeable), | 1972 | mergeability: toMergeability(pr.mergeable), |
| 1973 | ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), | ||
| 1968 | }); | 1974 | }); |
| 1969 | } | 1975 | } |
| 1970 | return Result.succeed(summaries); | 1976 | return Result.succeed(summaries); |
apps/server/src/sourceControl/GitHubCli.test.ts+317-14| ... | @@ -4,6 +4,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -4,6 +4,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 4 | import * as Clock from "effect/Clock"; | 4 | import * as Clock from "effect/Clock"; |
| 5 | import * as DateTime from "effect/DateTime"; | 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as Redacted from "effect/Redacted"; | 10 | import * as Redacted from "effect/Redacted"; |
| ... | @@ -31,13 +32,18 @@ const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") => | ... | @@ -31,13 +32,18 @@ const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") => |
| 31 | JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }), | 32 | JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }), |
| 32 | ); | 33 | ); |
| 33 | 34 | ||
| 35 | const isBudgetReading = (input: VcsProcess.VcsProcessInput) => | ||
| 36 | input.args[0] === "api" && | ||
| 37 | input.args[1] === "graphql" && | ||
| 38 | input.args.at(-1)?.includes("rateLimit"); | ||
| 39 | |||
| 34 | const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); | 40 | const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); |
| 35 | 41 | ||
| 42 | // Budget readings are answered here, so `mockRun` sees only the commands under test. | ||
| 36 | const layer = GitHubCli.layer.pipe( | 43 | const layer = GitHubCli.layer.pipe( |
| 37 | Layer.provide( | 44 | Layer.provide( |
| 38 | Layer.mock(VcsProcess.VcsProcess)({ | 45 | Layer.mock(VcsProcess.VcsProcess)({ |
| 39 | run: (input) => | 46 | run: (input) => (isBudgetReading(input) ? Effect.succeed(quotaOutput()) : mockRun(input)), |
| 40 | input.args[1] === "rate_limit" ? Effect.succeed(quotaOutput()) : mockRun(input), | ||
| 41 | }), | 47 | }), |
| 42 | ), | 48 | ), |
| 43 | ); | 49 | ); |
| ... | @@ -46,9 +52,9 @@ afterEach(() => { | ... | @@ -46,9 +52,9 @@ afterEach(() => { |
| 46 | mockRun.mockReset(); | 52 | mockRun.mockReset(); |
| 47 | }); | 53 | }); |
| 48 | 54 | ||
| 49 | it.effect("shares quota checks, preserves the reserve, and resumes after reset", () => | 55 | it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () => |
| 50 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| 51 | let probes = 0; | 57 | let readings = 0; |
| 52 | const commands: string[] = []; | 58 | const commands: string[] = []; |
| 53 | let remaining = 501; | 59 | let remaining = 501; |
| 54 | let resetAt = DateTime.formatIso( | 60 | let resetAt = DateTime.formatIso( |
| ... | @@ -58,8 +64,8 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset", | ... | @@ -58,8 +64,8 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset", |
| 58 | Effect.provideService(VcsProcess.VcsProcess, { | 64 | Effect.provideService(VcsProcess.VcsProcess, { |
| 59 | run: (input) => | 65 | run: (input) => |
| 60 | Effect.sync(() => { | 66 | Effect.sync(() => { |
| 61 | if (input.args[1] === "rate_limit") { | 67 | if (isBudgetReading(input)) { |
| 62 | probes++; | 68 | readings++; |
| 63 | assert.strictEqual(input.args[3], "enterprise.test"); | 69 | assert.strictEqual(input.args[3], "enterprise.test"); |
| 64 | return quotaOutput(remaining, resetAt); | 70 | return quotaOutput(remaining, resetAt); |
| 65 | } | 71 | } |
| ... | @@ -79,24 +85,320 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset", | ... | @@ -79,24 +85,320 @@ it.effect("shares quota checks, preserves the reserve, and resumes after reset", |
| 79 | yield* read("list"); | 85 | yield* read("list"); |
| 80 | const failure = yield* read("view").pipe(Effect.flip); | 86 | const failure = yield* read("view").pipe(Effect.flip); |
| 81 | assert.strictEqual(failure._tag, "GitHubCliRateLimitError"); | 87 | assert.strictEqual(failure._tag, "GitHubCliRateLimitError"); |
| 82 | assert.strictEqual(probes, 1); | ||
| 83 | assert.deepStrictEqual(commands, ["pr list"]); | 88 | assert.deepStrictEqual(commands, ["pr list"]); |
| 84 | yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); | 89 | yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); |
| 85 | yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] }); | 90 | yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] }); |
| 86 | assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]); | 91 | assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]); |
| 87 | remaining = 0; | 92 | // One reading covers the whole window. |
| 88 | yield* TestClock.adjust("30 seconds"); | 93 | assert.strictEqual(readings, 1); |
| 89 | yield* read("repo").pipe(Effect.flip); | 94 | yield* TestClock.adjust("1 minute"); |
| 90 | assert.strictEqual(probes, 2); | ||
| 91 | yield* TestClock.adjust("30 seconds"); | ||
| 92 | remaining = 5000; | 95 | remaining = 5000; |
| 93 | resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000)); | 96 | resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000)); |
| 94 | yield* Effect.all([read("list"), read("repo")], { concurrency: 2 }); | 97 | yield* Effect.all([read("list"), read("repo")], { concurrency: 2 }); |
| 95 | assert.strictEqual(probes, 3); | 98 | assert.strictEqual(readings, 2); |
| 96 | assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]); | 99 | assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]); |
| 97 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | 100 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), |
| 98 | ); | 101 | ); |
| 99 | 102 | ||
| 103 | it.effect("reads the budget again at a near reset, and every ten minutes in a long window", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | let readings = 0; | ||
| 106 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 107 | let resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000)); | ||
| 108 | const gh = yield* GitHubCli.make.pipe( | ||
| 109 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 110 | run: (input) => | ||
| 111 | Effect.sync(() => { | ||
| 112 | if (!isBudgetReading(input)) return processOutput("[]"); | ||
| 113 | readings++; | ||
| 114 | return quotaOutput(5000, resetAt); | ||
| 115 | }), | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] }); | ||
| 119 | yield* read; | ||
| 120 | // The window resets ten seconds in, so the reading expires with it. | ||
| 121 | resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000)); | ||
| 122 | yield* TestClock.adjust("10 seconds"); | ||
| 123 | yield* read; | ||
| 124 | assert.strictEqual(readings, 2); | ||
| 125 | yield* TestClock.adjust("9 minutes"); | ||
| 126 | yield* read; | ||
| 127 | assert.strictEqual(readings, 2); | ||
| 128 | yield* TestClock.adjust("1 minute"); | ||
| 129 | yield* read; | ||
| 130 | assert.strictEqual(readings, 3); | ||
| 131 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | ||
| 132 | ); | ||
| 133 | |||
| 134 | it.effect("reads anyway when the budget reading fails", () => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const gh = yield* GitHubCli.make.pipe( | ||
| 137 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 138 | run: (input) => | ||
| 139 | isBudgetReading(input) | ||
| 140 | ? Effect.fail( | ||
| 141 | new VcsProcessSpawnError({ | ||
| 142 | operation: "GitHubCli.execute", | ||
| 143 | command: "gh", | ||
| 144 | cwd: "/gone", | ||
| 145 | cause: new Error("ENOENT"), | ||
| 146 | }), | ||
| 147 | ) | ||
| 148 | : Effect.succeed(processOutput("[]")), | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }); | ||
| 152 | assert.strictEqual(result.stdout, "[]"); | ||
| 153 | }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), | ||
| 154 | ); | ||
| 155 | |||
| 156 | describe("selectGitHubBaseRepository", () => { | ||
| 157 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | ||
| 158 | entries | ||
| 159 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | ||
| 160 | .join("\n"); | ||
| 161 | const select = (input: { remotes: string; resolved?: string }) => | ||
| 162 | GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | ||
| 163 | |||
| 164 | it("picks the repository gh reads without a prompt", () => { | ||
| 165 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | ||
| 166 | owner: "me", | ||
| 167 | name: "web", | ||
| 168 | }); | ||
| 169 | const fork = remotes( | ||
| 170 | ["origin", "git@github.com:me/web.git"], | ||
| 171 | ["upstream", "https://github.com/Acme/Web.git"], | ||
| 172 | ); | ||
| 173 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | ||
| 174 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | ||
| 175 | owner: "me", | ||
| 176 | name: "web", | ||
| 177 | }); | ||
| 178 | assert.deepStrictEqual( | ||
| 179 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | ||
| 180 | { owner: "acme", name: "other" }, | ||
| 181 | ); | ||
| 182 | // gh ranks remote names in any case. | ||
| 183 | assert.deepStrictEqual( | ||
| 184 | select({ | ||
| 185 | remotes: remotes( | ||
| 186 | ["origin", "git@github.com:me/web.git"], | ||
| 187 | ["Upstream", "git@github.com:acme/web.git"], | ||
| 188 | ), | ||
| 189 | }), | ||
| 190 | { owner: "acme", name: "web" }, | ||
| 191 | ); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("leaves gh to choose when it might weigh the remotes differently", () => { | ||
| 195 | for (const input of [ | ||
| 196 | { remotes: "" }, | ||
| 197 | { | ||
| 198 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | ||
| 199 | }, | ||
| 200 | { | ||
| 201 | remotes: remotes( | ||
| 202 | ["origin", "git@github.com:me/web.git"], | ||
| 203 | ["mirror", "git@gitlab.com:me/web.git"], | ||
| 204 | ), | ||
| 205 | }, | ||
| 206 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | ||
| 207 | { | ||
| 208 | remotes: remotes( | ||
| 209 | ["origin", "git@github.com:me/web.git"], | ||
| 210 | ["Origin", "git@github.com:acme/web.git"], | ||
| 211 | ), | ||
| 212 | }, | ||
| 213 | { | ||
| 214 | remotes: remotes( | ||
| 215 | ["origin", "git@github.com:me/web.git"], | ||
| 216 | ["upstream", "git@github.com:acme/web.git"], | ||
| 217 | ), | ||
| 218 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | ||
| 219 | }, | ||
| 220 | ]) { | ||
| 221 | assert.strictEqual(select(input), null); | ||
| 222 | } | ||
| 223 | }); | ||
| 224 | }); | ||
| 225 | |||
| 226 | describe("GitHubCli.listPullRequestsByHead", () => { | ||
| 227 | const remoteOutput = | ||
| 228 | "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n"; | ||
| 229 | const node = (number: number, headRefName: string) => ({ | ||
| 230 | number, | ||
| 231 | title: `PR ${number}`, | ||
| 232 | url: `https://github.com/acme/web/pull/${number}`, | ||
| 233 | baseRefName: "main", | ||
| 234 | headRefName, | ||
| 235 | state: "MERGED", | ||
| 236 | mergedAt: "2026-01-01T00:00:00Z", | ||
| 237 | updatedAt: "2026-01-02T00:00:00Z", | ||
| 238 | headRepository: { name: "web", nameWithOwner: "acme/web" }, | ||
| 239 | headRepositoryOwner: { login: "acme" }, | ||
| 240 | }); | ||
| 241 | const decodeRequest = Schema.decodeSync( | ||
| 242 | Schema.fromJsonString( | ||
| 243 | Schema.Struct({ | ||
| 244 | query: Schema.String, | ||
| 245 | variables: Schema.Record(Schema.String, Schema.Unknown), | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | ); | ||
| 249 | const jsonOutput = (value: unknown) => processOutput(JSON.stringify(value)); | ||
| 250 | const git = (input: VcsProcess.VcsProcessInput) => | ||
| 251 | input.args[0] === "remote" | ||
| 252 | ? processOutput(remoteOutput) | ||
| 253 | : { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(1) }; | ||
| 254 | |||
| 255 | it.effect("reads heads on one repository in one GraphQL document", () => | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const documents: Array<{ query: string; variables: Record<string, unknown> }> = []; | ||
| 258 | mockRun.mockImplementation((input) => | ||
| 259 | Effect.sync(() => { | ||
| 260 | if (input.command === "git") return git(input); | ||
| 261 | documents.push(decodeRequest(input.stdin ?? "")); | ||
| 262 | return jsonOutput({ | ||
| 263 | data: { | ||
| 264 | repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } }, | ||
| 265 | rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" }, | ||
| 266 | }, | ||
| 267 | }); | ||
| 268 | }), | ||
| 269 | ); | ||
| 270 | const gh = yield* GitHubCli.GitHubCli; | ||
| 271 | const lookups = yield* Effect.all( | ||
| 272 | ["feature/a", "feature/b"].map((headSelector) => | ||
| 273 | gh.listPullRequestsByHead({ | ||
| 274 | cwd: "/repo", | ||
| 275 | headSelector, | ||
| 276 | state: "all", | ||
| 277 | limit: 100, | ||
| 278 | rateLimitHost: "github.com", | ||
| 279 | }), | ||
| 280 | ), | ||
| 281 | { concurrency: "unbounded" }, | ||
| 282 | ).pipe(Effect.forkChild); | ||
| 283 | yield* TestClock.adjust("50 millis"); | ||
| 284 | const [first, second] = yield* Fiber.join(lookups); | ||
| 285 | assert.deepStrictEqual( | ||
| 286 | first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]), | ||
| 287 | [[7, "merged", "acme/web"]], | ||
| 288 | ); | ||
| 289 | assert.deepStrictEqual(second, []); | ||
| 290 | assert.strictEqual(documents.length, 1); | ||
| 291 | assert.include(documents[0]!.query, "rateLimit"); | ||
| 292 | assert.deepStrictEqual(documents[0]!.variables, { | ||
| 293 | owner: "acme", | ||
| 294 | name: "web", | ||
| 295 | h0: "feature/a", | ||
| 296 | s0: ["OPEN", "CLOSED", "MERGED"], | ||
| 297 | h1: "feature/b", | ||
| 298 | s1: ["OPEN", "CLOSED", "MERGED"], | ||
| 299 | }); | ||
| 300 | }).pipe(Effect.provide(layer)), | ||
| 301 | ); | ||
| 302 | |||
| 303 | it.effect("asks gh pr list when gh could read another repository", () => | ||
| 304 | Effect.gen(function* () { | ||
| 305 | const commands: Array<ReadonlyArray<string>> = []; | ||
| 306 | mockRun.mockImplementation((input) => | ||
| 307 | Effect.sync(() => { | ||
| 308 | commands.push([input.command, ...input.args]); | ||
| 309 | if (input.command === "git") { | ||
| 310 | return processOutput( | ||
| 311 | input.args[0] === "remote" | ||
| 312 | ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n" | ||
| 313 | : "", | ||
| 314 | ); | ||
| 315 | } | ||
| 316 | return input.args[3] === "feature/empty" | ||
| 317 | ? processOutput("") | ||
| 318 | : jsonOutput([node(8, "feature/a")]); | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | const gh = yield* GitHubCli.GitHubCli; | ||
| 322 | const pullRequests = yield* gh.listPullRequestsByHead({ | ||
| 323 | cwd: "/repo", | ||
| 324 | headSelector: "feature/a", | ||
| 325 | state: "all", | ||
| 326 | limit: 100, | ||
| 327 | rateLimitHost: "github.com", | ||
| 328 | }); | ||
| 329 | assert.deepStrictEqual( | ||
| 330 | pullRequests.map((pr) => pr.number), | ||
| 331 | [8], | ||
| 332 | ); | ||
| 333 | assert.deepStrictEqual(commands.at(-1), [ | ||
| 334 | "gh", | ||
| 335 | "pr", | ||
| 336 | "list", | ||
| 337 | "--head", | ||
| 338 | "feature/a", | ||
| 339 | "--state", | ||
| 340 | "all", | ||
| 341 | "--limit", | ||
| 342 | "100", | ||
| 343 | "--json", | ||
| 344 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 345 | ]); | ||
| 346 | const empty = yield* gh.listPullRequestsByHead({ | ||
| 347 | cwd: "/repo", | ||
| 348 | headSelector: "feature/empty", | ||
| 349 | state: "all", | ||
| 350 | limit: 100, | ||
| 351 | rateLimitHost: "github.com", | ||
| 352 | }); | ||
| 353 | assert.deepStrictEqual(empty, []); | ||
| 354 | }).pipe(Effect.provide(layer)), | ||
| 355 | ); | ||
| 356 | |||
| 357 | it.effect("fails a rate-limited document whole instead of asking head by head", () => | ||
| 358 | Effect.gen(function* () { | ||
| 359 | let ghCalls = 0; | ||
| 360 | mockRun.mockImplementation((input) => { | ||
| 361 | if (input.command === "git") return Effect.succeed(git(input)); | ||
| 362 | ghCalls++; | ||
| 363 | return Effect.fail( | ||
| 364 | new VcsProcessExitError({ | ||
| 365 | operation: "GitHubCli.execute", | ||
| 366 | command: "gh", | ||
| 367 | cwd: "/repo", | ||
| 368 | exitCode: 1, | ||
| 369 | failureKind: "rate-limited", | ||
| 370 | detail: "API rate limit exceeded.", | ||
| 371 | stderrLength: 24, | ||
| 372 | stderrTruncated: false, | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | }); | ||
| 376 | const gh = yield* GitHubCli.GitHubCli; | ||
| 377 | const lookups = yield* Effect.all( | ||
| 378 | ["feature/a", "feature/b"].map((headSelector) => | ||
| 379 | gh | ||
| 380 | .listPullRequestsByHead({ | ||
| 381 | cwd: "/repo", | ||
| 382 | headSelector, | ||
| 383 | state: "all", | ||
| 384 | limit: 100, | ||
| 385 | rateLimitHost: "github.com", | ||
| 386 | }) | ||
| 387 | .pipe(Effect.flip), | ||
| 388 | ), | ||
| 389 | { concurrency: "unbounded" }, | ||
| 390 | ).pipe(Effect.forkChild); | ||
| 391 | yield* TestClock.adjust("50 millis"); | ||
| 392 | const errors = yield* Fiber.join(lookups); | ||
| 393 | assert.deepStrictEqual( | ||
| 394 | errors.map((error) => error._tag), | ||
| 395 | ["GitHubCliRateLimitError", "GitHubCliRateLimitError"], | ||
| 396 | ); | ||
| 397 | assert.strictEqual(ghCalls, 1); | ||
| 398 | }).pipe(Effect.provide(layer)), | ||
| 399 | ); | ||
| 400 | }); | ||
| 401 | |||
| 100 | describe("GitHubCli.layer", () => { | 402 | describe("GitHubCli.layer", () => { |
| 101 | it.effect("shares the registry budget with CLI reads through nested layer providers", () => | 403 | it.effect("shares the registry budget with CLI reads through nested layer providers", () => |
| 102 | Effect.gen(function* () { | 404 | Effect.gen(function* () { |
| ... | @@ -116,8 +418,9 @@ describe("GitHubCli.layer", () => { | ... | @@ -116,8 +418,9 @@ describe("GitHubCli.layer", () => { |
| 116 | Effect.provideService(VcsProcess.VcsProcess, { | 418 | Effect.provideService(VcsProcess.VcsProcess, { |
| 117 | run: (input) => | 419 | run: (input) => |
| 118 | Effect.sync(() => { | 420 | Effect.sync(() => { |
| 119 | if (input.args[1] === "rate_limit") | 421 | if (isBudgetReading(input)) { |
| 120 | return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000); | 422 | return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000); |
| 423 | } | ||
| 121 | reads++; | 424 | reads++; |
| 122 | return processOutput("[]"); | 425 | return processOutput("[]"); |
| 123 | }), | 426 | }), |
apps/server/src/sourceControl/GitHubCli.ts+435-45| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Cache from "effect/Cache"; | 1 | import * as Cache from "effect/Cache"; |
| 2 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 3 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 4 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| ... | @@ -8,6 +9,8 @@ import * as Layer from "effect/Layer"; | ... | @@ -8,6 +9,8 @@ import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 9 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Redacted from "effect/Redacted"; | 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Request from "effect/Request"; | ||
| 13 | import * as RequestResolver from "effect/RequestResolver"; | ||
| 11 | import * as Result from "effect/Result"; | 14 | import * as Result from "effect/Result"; |
| 12 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 13 | 16 | ||
| ... | @@ -16,11 +19,14 @@ import { | ... | @@ -16,11 +19,14 @@ import { |
| 16 | type SourceControlRepositoryVisibility, | 19 | type SourceControlRepositoryVisibility, |
| 17 | type VcsError, | 20 | type VcsError, |
| 18 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 22 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | ||
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 19 | 24 | ||
| 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 25 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 21 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | 26 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; |
| 22 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | 27 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 23 | import { | 28 | import { |
| 29 | decodeGitHubPullRequestEntries, | ||
| 24 | decodeGitHubPullRequestJson, | 30 | decodeGitHubPullRequestJson, |
| 25 | decodeGitHubPullRequestListJson, | 31 | decodeGitHubPullRequestListJson, |
| 26 | type NormalizedGitHubPullRequestRecord, | 32 | type NormalizedGitHubPullRequestRecord, |
| ... | @@ -300,6 +306,20 @@ export class GitHubCli extends Context.Service< | ... | @@ -300,6 +306,20 @@ export class GitHubCli extends Context.Service< |
| 300 | readonly rateLimitHost?: string; | 306 | readonly rateLimitHost?: string; |
| 301 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | 307 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; |
| 302 | 308 | ||
| 309 | /** | ||
| 310 | * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in | ||
| 311 | * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a | ||
| 312 | * checkout whose repository gh could pick another way is asked through `gh pr list`. | ||
| 313 | */ | ||
| 314 | readonly listPullRequestsByHead: (input: { | ||
| 315 | readonly cwd: string; | ||
| 316 | readonly headSelector: string; | ||
| 317 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 318 | readonly limit: number; | ||
| 319 | /** The checkout's GitHub host. Without it the lookup is not batched. */ | ||
| 320 | readonly rateLimitHost?: string; | ||
| 321 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>; | ||
| 322 | |||
| 303 | readonly getPullRequest: (input: { | 323 | readonly getPullRequest: (input: { |
| 304 | readonly cwd: string; | 324 | readonly cwd: string; |
| 305 | readonly reference: string; | 325 | readonly reference: string; |
| ... | @@ -394,6 +414,149 @@ function deriveRepositoryCloneUrlsFromCreateOutput( | ... | @@ -394,6 +414,149 @@ function deriveRepositoryCloneUrlsFromCreateOutput( |
| 394 | }; | 414 | }; |
| 395 | } | 415 | } |
| 396 | 416 | ||
| 417 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | ||
| 418 | |||
| 419 | const PULL_REQUEST_LIST_JSON_FIELDS = | ||
| 420 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner"; | ||
| 421 | /** The `gh pr list --json` fields above, as GraphQL selects them. */ | ||
| 422 | const PULL_REQUEST_NODE_SELECTION = | ||
| 423 | "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | ||
| 424 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | ||
| 425 | open: ["OPEN"], | ||
| 426 | closed: ["CLOSED"], | ||
| 427 | merged: ["MERGED"], | ||
| 428 | all: ["OPEN", "CLOSED", "MERGED"], | ||
| 429 | }; | ||
| 430 | /** | ||
| 431 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | ||
| 432 | * `gh pr list`, but half that keeps each answer near half a second. | ||
| 433 | */ | ||
| 434 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | ||
| 435 | /** | ||
| 436 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | ||
| 437 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | ||
| 438 | */ | ||
| 439 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | ||
| 440 | /** A full document is 5,000 rows of well under 2 KB each. */ | ||
| 441 | const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000; | ||
| 442 | const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }"; | ||
| 443 | |||
| 444 | class PullRequestsByHeadRead extends Request.Class< | ||
| 445 | { | ||
| 446 | readonly cwd: string; | ||
| 447 | readonly host: string; | ||
| 448 | readonly owner: string; | ||
| 449 | readonly name: string; | ||
| 450 | readonly headRefName: string; | ||
| 451 | readonly state: PullRequestListState; | ||
| 452 | readonly limit: number; | ||
| 453 | }, | ||
| 454 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | ||
| 455 | GitHubCliError | ||
| 456 | > {} | ||
| 457 | |||
| 458 | const GraphQlVariables = Schema.Record( | ||
| 459 | Schema.String, | ||
| 460 | Schema.Union([Schema.String, Schema.Array(Schema.String)]), | ||
| 461 | ); | ||
| 462 | /** A GraphQL request body for `gh api graphql --input -`. */ | ||
| 463 | const encodeGraphQlRequest = Schema.encodeSync( | ||
| 464 | Schema.fromJsonString(Schema.Struct({ query: Schema.String, variables: GraphQlVariables })), | ||
| 465 | ); | ||
| 466 | |||
| 467 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | ||
| 468 | function buildPullRequestsByHeadQuery( | ||
| 469 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | ||
| 470 | ): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } { | ||
| 471 | const variables: Record<string, string | ReadonlyArray<string>> = {}; | ||
| 472 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | ||
| 473 | const selections: string[] = []; | ||
| 474 | for (const [index, lookup] of lookups.entries()) { | ||
| 475 | variables[`h${index}`] = lookup.headRefName; | ||
| 476 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | ||
| 477 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | ||
| 478 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | ||
| 479 | // same rows. | ||
| 480 | selections.push( | ||
| 481 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | ||
| 482 | ); | ||
| 483 | } | ||
| 484 | return { | ||
| 485 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | ||
| 486 | variables, | ||
| 487 | }; | ||
| 488 | } | ||
| 489 | |||
| 490 | /** The reset time of a `rateLimit` reading, which sets when the next one is due. */ | ||
| 491 | const decodeRateLimitReading = (raw: string) => | ||
| 492 | Result.map( | ||
| 493 | decodeJsonResult( | ||
| 494 | Schema.Struct({ | ||
| 495 | data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }), | ||
| 496 | }), | ||
| 497 | )(raw), | ||
| 498 | (reading) => reading.data.rateLimit.resetAt, | ||
| 499 | ); | ||
| 500 | |||
| 501 | const decodePullRequestsByHead = decodeJsonResult( | ||
| 502 | Schema.Struct({ | ||
| 503 | data: Schema.Struct({ | ||
| 504 | repository: Schema.Record( | ||
| 505 | Schema.String, | ||
| 506 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | ||
| 507 | ), | ||
| 508 | }), | ||
| 509 | }), | ||
| 510 | ); | ||
| 511 | |||
| 512 | /** | ||
| 513 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | ||
| 514 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | ||
| 515 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | ||
| 516 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | ||
| 517 | * | ||
| 518 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | ||
| 519 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | ||
| 520 | * ask gh itself. | ||
| 521 | */ | ||
| 522 | export function selectGitHubBaseRepository(input: { | ||
| 523 | readonly remotes: string; | ||
| 524 | readonly resolved: string; | ||
| 525 | readonly host: string; | ||
| 526 | }): { readonly owner: string; readonly name: string } | null { | ||
| 527 | const host = input.host.toLowerCase(); | ||
| 528 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | ||
| 529 | for (const line of input.remotes.split("\n")) { | ||
| 530 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | ||
| 531 | if (!match) continue; | ||
| 532 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | ||
| 533 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | ||
| 534 | repositories.set(match[1]!, { owner, name }); | ||
| 535 | } | ||
| 536 | const marks = input.resolved | ||
| 537 | .split("\n") | ||
| 538 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | ||
| 539 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | ||
| 540 | if (marks.length > 1) return null; | ||
| 541 | const [mark] = marks; | ||
| 542 | if (mark) { | ||
| 543 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | ||
| 544 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | ||
| 545 | return owner && name && rest.length === 0 ? { owner, name } : null; | ||
| 546 | } | ||
| 547 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | ||
| 548 | // top place has no defined winner. | ||
| 549 | const score = (remoteName: string) => | ||
| 550 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | ||
| 551 | const ranked = [...repositories.entries()].toSorted( | ||
| 552 | ([left], [right]) => score(right) - score(left), | ||
| 553 | ); | ||
| 554 | const [top, next] = ranked; | ||
| 555 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | ||
| 556 | ? top[1] | ||
| 557 | : null; | ||
| 558 | } | ||
| 559 | |||
| 397 | /** @public Service construction is part of the canonical Effect module API. */ | 560 | /** @public Service construction is part of the canonical Effect module API. */ |
| 398 | export const make = Effect.gen(function* () { | 561 | export const make = Effect.gen(function* () { |
| 399 | const process = yield* VcsProcess.VcsProcess; | 562 | const process = yield* VcsProcess.VcsProcess; |
| ... | @@ -438,66 +601,86 @@ export const make = Effect.gen(function* () { | ... | @@ -438,66 +601,86 @@ export const make = Effect.gen(function* () { |
| 438 | }, | 601 | }, |
| 439 | ); | 602 | ); |
| 440 | 603 | ||
| 441 | const quota = yield* Cache.makeWith( | 604 | /** |
| 605 | * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance | ||
| 606 | * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it | ||
| 607 | * current in between. Each reading costs one point and is redone at the reset, or after ten | ||
| 608 | * minutes so a `gh auth switch` is not priced against the old account for a whole hour. | ||
| 609 | */ | ||
| 610 | const budgetReading = yield* Cache.makeWith( | ||
| 442 | (key: string) => { | 611 | (key: string) => { |
| 443 | const host = key.split("\0")[0]!; | 612 | const host = key.split("\0")[0]!; |
| 444 | return executeRaw({ | 613 | return executeRaw({ |
| 445 | cwd: globalThis.process.cwd(), | 614 | cwd: globalThis.process.cwd(), |
| 446 | args: [ | 615 | args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`], |
| 447 | "api", | ||
| 448 | "rate_limit", | ||
| 449 | "--hostname", | ||
| 450 | host, | ||
| 451 | "--jq", | ||
| 452 | ".resources.graphql | {data:{rateLimit:{cost:1,limit:.limit,remaining:.remaining,resetAt:(.reset|todateiso8601)}}}", | ||
| 453 | ], | ||
| 454 | }).pipe( | 616 | }).pipe( |
| 455 | Effect.tap((result) => budget.observe(host, result.stdout)), | 617 | Effect.tap((result) => budget.observe(host, result.stdout)), |
| 456 | Effect.asVoid, | 618 | Effect.flatMap((result) => |
| 619 | Clock.currentTimeMillis.pipe( | ||
| 620 | Effect.map((now) => { | ||
| 621 | const resetAtMs = Date.parse( | ||
| 622 | decodeRateLimitReading(result.stdout).pipe( | ||
| 623 | Result.match({ onFailure: () => "", onSuccess: (reading) => reading }), | ||
| 624 | ), | ||
| 625 | ); | ||
| 626 | // A reset that is missing or already past (a skewed clock) waits a minute, so a | ||
| 627 | // bad reading cannot ask again on every read. | ||
| 628 | return Duration.millis( | ||
| 629 | Number.isFinite(resetAtMs) && resetAtMs > now | ||
| 630 | ? Math.min(resetAtMs - now, 600_000) | ||
| 631 | : 60_000, | ||
| 632 | ); | ||
| 633 | }), | ||
| 634 | ), | ||
| 635 | ), | ||
| 457 | ); | 636 | ); |
| 458 | }, | 637 | }, |
| 459 | { | 638 | { |
| 460 | capacity: 32, | 639 | capacity: 32, |
| 461 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(30) : Duration.zero), | 640 | timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.minutes(1)), |
| 462 | }, | 641 | }, |
| 463 | ); | 642 | ); |
| 464 | const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( | 643 | |
| 465 | function* (input) { | 644 | /** |
| 466 | const [command, action] = input.args; | 645 | * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives |
| 467 | if ( | 646 | * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read |
| 468 | !( | 647 | * ignores it and is priced at one point. |
| 469 | (command === "pr" && (action === "list" || action === "view")) || | 648 | */ |
| 470 | (command === "repo" && action === "view") | 649 | const guardedRead = <A>(input: { |
| 471 | ) | 650 | readonly cwd: string; |
| 472 | ) | 651 | readonly host: string; |
| 473 | return yield* executeRaw(input); | 652 | readonly document: string; |
| 653 | readonly allowReserve: boolean; | ||
| 654 | readonly run: (document: string) => Effect.Effect<A, GitHubCliError>; | ||
| 655 | }) => | ||
| 656 | Effect.gen(function* () { | ||
| 474 | const credential = yield* PinnedGitHubCredential; | 657 | const credential = yield* PinnedGitHubCredential; |
| 475 | if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) | 658 | const key = { provider: "github" as const, host: input.host }; |
| 476 | return yield* executeRaw(input); | ||
| 477 | const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); | ||
| 478 | const host = ( | ||
| 479 | credential?.host ?? | ||
| 480 | commandHosts(input.args).find((host) => host !== null) ?? | ||
| 481 | input.rateLimitHost ?? | ||
| 482 | input.env?.GH_HOST ?? | ||
| 483 | globalThis.process.env.GH_HOST ?? | ||
| 484 | "github.com" | ||
| 485 | ).toLowerCase(); | ||
| 486 | const key = { provider: "github" as const, host }; | ||
| 487 | const guarded = Effect.gen(function* () { | 659 | const guarded = Effect.gen(function* () { |
| 488 | const lease = yield* limits.check(key, allowReserve ? { allowPaused: true } : undefined); | 660 | const lease = yield* limits.check( |
| 489 | return yield* Effect.gen(function* () { | 661 | key, |
| 490 | yield* Cache.get(quota, `${host}\0${credential?.credentialFingerprint ?? ""}`); | 662 | input.allowReserve ? { allowPaused: true } : undefined, |
| 491 | yield* budget.query(host, "query {}", allowReserve ? { allowReserve: true } : undefined); | ||
| 492 | return yield* executeRaw(input); | ||
| 493 | }).pipe( | ||
| 494 | Effect.tap(() => limits.recordSuccess({ ...key, lease })), | ||
| 495 | Effect.tapError((error) => | ||
| 496 | error._tag === "GitHubCliRateLimitError" | ||
| 497 | ? limits.recordRateLimit({ ...key, lease }) | ||
| 498 | : Effect.void, | ||
| 499 | ), | ||
| 500 | ); | 663 | ); |
| 664 | // A failed reading leaves the budget unknown; it never blocks the read itself. | ||
| 665 | yield* Cache.get( | ||
| 666 | budgetReading, | ||
| 667 | `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`, | ||
| 668 | ).pipe(Effect.ignore); | ||
| 669 | return yield* budget | ||
| 670 | .query( | ||
| 671 | input.host, | ||
| 672 | input.document, | ||
| 673 | input.allowReserve ? { allowReserve: true } : undefined, | ||
| 674 | ) | ||
| 675 | .pipe( | ||
| 676 | Effect.flatMap(input.run), | ||
| 677 | Effect.tap(() => limits.recordSuccess({ ...key, lease })), | ||
| 678 | Effect.tapError((error) => | ||
| 679 | error._tag === "GitHubCliRateLimitError" | ||
| 680 | ? limits.recordRateLimit({ ...key, lease }) | ||
| 681 | : Effect.void, | ||
| 682 | ), | ||
| 683 | ); | ||
| 501 | }); | 684 | }); |
| 502 | return yield* guarded.pipe( | 685 | return yield* guarded.pipe( |
| 503 | Effect.provideService( | 686 | Effect.provideService( |
| ... | @@ -516,11 +699,218 @@ export const make = Effect.gen(function* () { | ... | @@ -516,11 +699,218 @@ export const make = Effect.gen(function* () { |
| 516 | ), | 699 | ), |
| 517 | }), | 700 | }), |
| 518 | ); | 701 | ); |
| 702 | }); | ||
| 703 | |||
| 704 | const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( | ||
| 705 | function* (input) { | ||
| 706 | const [command, action] = input.args; | ||
| 707 | if ( | ||
| 708 | !( | ||
| 709 | (command === "pr" && (action === "list" || action === "view")) || | ||
| 710 | (command === "repo" && action === "view") | ||
| 711 | ) | ||
| 712 | ) | ||
| 713 | return yield* executeRaw(input); | ||
| 714 | const credential = yield* PinnedGitHubCredential; | ||
| 715 | if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) | ||
| 716 | return yield* executeRaw(input); | ||
| 717 | return yield* guardedRead({ | ||
| 718 | cwd: input.cwd, | ||
| 719 | host: ( | ||
| 720 | credential?.host ?? | ||
| 721 | commandHosts(input.args).find((host) => host !== null) ?? | ||
| 722 | input.rateLimitHost ?? | ||
| 723 | input.env?.GH_HOST ?? | ||
| 724 | globalThis.process.env.GH_HOST ?? | ||
| 725 | "github.com" | ||
| 726 | ).toLowerCase(), | ||
| 727 | document: "query {}", | ||
| 728 | allowReserve: input.allowReserve ?? (yield* AllowGitHubReserve), | ||
| 729 | run: () => executeRaw(input), | ||
| 730 | }); | ||
| 731 | }, | ||
| 732 | ); | ||
| 733 | |||
| 734 | const listPullRequestsWithCli = (input: { | ||
| 735 | readonly cwd: string; | ||
| 736 | readonly headSelector: string; | ||
| 737 | readonly state: PullRequestListState; | ||
| 738 | readonly limit: number; | ||
| 739 | readonly rateLimitHost?: string | undefined; | ||
| 740 | }) => | ||
| 741 | execute({ | ||
| 742 | cwd: input.cwd, | ||
| 743 | ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), | ||
| 744 | args: [ | ||
| 745 | "pr", | ||
| 746 | "list", | ||
| 747 | "--head", | ||
| 748 | input.headSelector, | ||
| 749 | "--state", | ||
| 750 | input.state, | ||
| 751 | "--limit", | ||
| 752 | String(input.limit), | ||
| 753 | "--json", | ||
| 754 | PULL_REQUEST_LIST_JSON_FIELDS, | ||
| 755 | ], | ||
| 756 | }).pipe( | ||
| 757 | Effect.flatMap((result) => { | ||
| 758 | const raw = result.stdout.trim(); | ||
| 759 | if (raw.length === 0) return Effect.succeed([]); | ||
| 760 | const decoded = decodeGitHubPullRequestListJson(raw); | ||
| 761 | return Result.isSuccess(decoded) | ||
| 762 | ? Effect.succeed(decoded.success) | ||
| 763 | : Effect.fail( | ||
| 764 | new GitHubChangeRequestListDecodeError({ | ||
| 765 | command: "gh", | ||
| 766 | cwd: input.cwd, | ||
| 767 | cause: decoded.failure, | ||
| 768 | }), | ||
| 769 | ); | ||
| 770 | }), | ||
| 771 | ); | ||
| 772 | |||
| 773 | const git = (cwd: string, args: ReadonlyArray<string>) => | ||
| 774 | process.run({ | ||
| 775 | operation: "GitHubCli.baseRepository", | ||
| 776 | command: "git", | ||
| 777 | args, | ||
| 778 | cwd, | ||
| 779 | allowNonZeroExit: true, | ||
| 780 | timeoutMs: 5_000, | ||
| 781 | }); | ||
| 782 | |||
| 783 | /** The repository gh reads in `cwd`, or null when gh could pick it another way. */ | ||
| 784 | const resolveBaseRepository = (cwd: string, host: string) => | ||
| 785 | globalThis.process.env.GH_REPO | ||
| 786 | ? Effect.succeed(null) | ||
| 787 | : Effect.all([ | ||
| 788 | git(cwd, ["remote", "-v"]), | ||
| 789 | git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | ||
| 790 | ]).pipe( | ||
| 791 | Effect.map(([remotes, resolved]) => | ||
| 792 | remotes.exitCode === 0 | ||
| 793 | ? selectGitHubBaseRepository({ | ||
| 794 | remotes: remotes.stdout, | ||
| 795 | resolved: resolved.exitCode === 0 ? resolved.stdout : "", | ||
| 796 | host, | ||
| 797 | }) | ||
| 798 | : null, | ||
| 799 | ), | ||
| 800 | Effect.orElseSucceed(() => null), | ||
| 801 | ); | ||
| 802 | |||
| 803 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | ||
| 804 | key: ({ request, context }) => | ||
| 805 | JSON.stringify([ | ||
| 806 | request.host, | ||
| 807 | request.owner, | ||
| 808 | request.name, | ||
| 809 | Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ?? | ||
| 810 | null, | ||
| 811 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | ||
| 812 | ]), | ||
| 813 | resolver: (entries) => { | ||
| 814 | const [first] = entries; | ||
| 815 | const { cwd, host, owner, name } = first.request; | ||
| 816 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); | ||
| 817 | const readCli = (entry: (typeof entries)[number]) => | ||
| 818 | listPullRequestsWithCli({ | ||
| 819 | cwd: entry.request.cwd, | ||
| 820 | headSelector: entry.request.headRefName, | ||
| 821 | state: entry.request.state, | ||
| 822 | limit: entry.request.limit, | ||
| 823 | rateLimitHost: entry.request.host, | ||
| 824 | }).pipe( | ||
| 825 | Effect.exit, | ||
| 826 | Effect.map((exit) => entry.completeUnsafe(exit)), | ||
| 827 | ); | ||
| 828 | return guardedRead({ | ||
| 829 | cwd, | ||
| 830 | host, | ||
| 831 | document: query.document, | ||
| 832 | allowReserve: false, | ||
| 833 | run: (document) => | ||
| 834 | executeRaw({ | ||
| 835 | cwd, | ||
| 836 | args: ["api", "graphql", "--hostname", host, "--input", "-"], | ||
| 837 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a | ||
| 838 | // hundred fork pull requests, so the 1 MB default would cut the answer short. | ||
| 839 | maxOutputBytes: HEAD_LOOKUP_MAX_OUTPUT_BYTES, | ||
| 840 | stdin: encodeGraphQlRequest({ | ||
| 841 | query: document, | ||
| 842 | variables: { owner, name, ...query.variables }, | ||
| 843 | }), | ||
| 844 | }).pipe(Effect.tap((result) => budget.observe(host, result.stdout))), | ||
| 845 | }).pipe( | ||
| 846 | Effect.flatMap((result) => { | ||
| 847 | const decoded = decodePullRequestsByHead(result.stdout); | ||
| 848 | if (!Result.isSuccess(decoded)) { | ||
| 849 | return Effect.forEach(entries, readCli, { discard: true }); | ||
| 850 | } | ||
| 851 | const aliases = decoded.success.data.repository; | ||
| 852 | return Effect.forEach( | ||
| 853 | entries, | ||
| 854 | (entry, index) => { | ||
| 855 | const alias = aliases[`h${index}`]; | ||
| 856 | if (alias == null) return readCli(entry); | ||
| 857 | entry.completeUnsafe(Exit.succeed(decodeGitHubPullRequestEntries(alias.nodes))); | ||
| 858 | return Effect.void; | ||
| 859 | }, | ||
| 860 | { discard: true }, | ||
| 861 | ); | ||
| 862 | }), | ||
| 863 | // A document GitHub refused as a whole (a renamed repository, a field an older | ||
| 864 | // Enterprise host lacks) leaves each lookup to gh. A rate limit fails them all: | ||
| 865 | // asking one at a time would only spend what the pause is saving. | ||
| 866 | Effect.catchIf( | ||
| 867 | (error) => error._tag !== "GitHubCliRateLimitError", | ||
| 868 | () => Effect.forEach(entries, readCli, { discard: true }), | ||
| 869 | ), | ||
| 870 | Effect.catchCause((cause) => | ||
| 871 | Effect.sync(() => { | ||
| 872 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | ||
| 873 | }), | ||
| 874 | ), | ||
| 875 | ); | ||
| 519 | }, | 876 | }, |
| 877 | }).pipe( | ||
| 878 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | ||
| 879 | RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT), | ||
| 520 | ); | 880 | ); |
| 521 | 881 | ||
| 882 | const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn( | ||
| 883 | "GitHubCli.listPullRequestsByHead", | ||
| 884 | )(function* (input) { | ||
| 885 | const host = input.rateLimitHost?.toLowerCase(); | ||
| 886 | const credential = yield* PinnedGitHubCredential; | ||
| 887 | // `owner:branch` selectors and other hosts keep gh's own handling. | ||
| 888 | const repository = | ||
| 889 | host === undefined || | ||
| 890 | input.headSelector.includes(":") || | ||
| 891 | (credential !== null && credential.host !== host) | ||
| 892 | ? null | ||
| 893 | : yield* resolveBaseRepository(input.cwd, host); | ||
| 894 | if (host === undefined || repository === null) { | ||
| 895 | return yield* listPullRequestsWithCli(input); | ||
| 896 | } | ||
| 897 | return yield* Effect.request( | ||
| 898 | new PullRequestsByHeadRead({ | ||
| 899 | cwd: input.cwd, | ||
| 900 | host, | ||
| 901 | owner: repository.owner, | ||
| 902 | name: repository.name, | ||
| 903 | headRefName: input.headSelector, | ||
| 904 | state: input.state, | ||
| 905 | limit: Math.min(Math.max(Math.trunc(input.limit), 1), 100), | ||
| 906 | }), | ||
| 907 | headResolver, | ||
| 908 | ); | ||
| 909 | }); | ||
| 910 | |||
| 522 | return GitHubCli.of({ | 911 | return GitHubCli.of({ |
| 523 | execute, | 912 | execute, |
| 913 | listPullRequestsByHead, | ||
| 524 | listOpenPullRequests: (input) => | 914 | listOpenPullRequests: (input) => |
| 525 | execute({ | 915 | execute({ |
| 526 | cwd: input.cwd, | 916 | cwd: input.cwd, |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+42-62| ... | @@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 7 | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import * as GitHubCli from "./GitHubCli.ts"; | 9 | import * as GitHubCli from "./GitHubCli.ts"; |
| 10 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | ||
| 10 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | 11 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 12 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 12 | 13 | ||
| ... | @@ -32,19 +33,13 @@ function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) { | ... | @@ -32,19 +33,13 @@ function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) { |
| 32 | 33 | ||
| 33 | it.effect("uses the enterprise quota for a current-repository default branch read", () => | 34 | it.effect("uses the enterprise quota for a current-repository default branch read", () => |
| 34 | Effect.gen(function* () { | 35 | Effect.gen(function* () { |
| 35 | const provider = yield* GitHubSourceControlProvider.make.pipe( | 36 | // github.com is out of quota; the enterprise read must not be priced against it. |
| 36 | Effect.provide(GitHubCli.layer), | 37 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; |
| 37 | Effect.provideService(VcsProcess.VcsProcess, { | 38 | yield* budget.observe( |
| 38 | run: (input) => | 39 | "github.com", |
| 39 | Effect.sync(() => { | 40 | '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}', |
| 40 | if (input.args[1] !== "rate_limit") return processResult("main"); | ||
| 41 | assert.strictEqual(input.args[3], "enterprise.test"); | ||
| 42 | return processResult( | ||
| 43 | '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":5000,"resetAt":"2099-01-01T00:00:00Z"}}}', | ||
| 44 | ); | ||
| 45 | }), | ||
| 46 | }), | ||
| 47 | ); | 41 | ); |
| 42 | const provider = yield* GitHubSourceControlProvider.make; | ||
| 48 | const branch = yield* provider.getDefaultBranch({ | 43 | const branch = yield* provider.getDefaultBranch({ |
| 49 | cwd: "/enterprise-repo", | 44 | cwd: "/enterprise-repo", |
| 50 | context: { | 45 | context: { |
| ... | @@ -54,7 +49,12 @@ it.effect("uses the enterprise quota for a current-repository default branch rea | ... | @@ -54,7 +49,12 @@ it.effect("uses the enterprise quota for a current-repository default branch rea |
| 54 | }, | 49 | }, |
| 55 | }); | 50 | }); |
| 56 | assert.strictEqual(branch, "main"); | 51 | assert.strictEqual(branch, "main"); |
| 57 | }), | 52 | }).pipe( |
| 53 | Effect.provide(GitHubCli.layer), | ||
| 54 | Effect.provideService(VcsProcess.VcsProcess, { | ||
| 55 | run: () => Effect.succeed(processResult("main")), | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ); | 58 | ); |
| 59 | 59 | ||
| 60 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | 60 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => |
| ... | @@ -138,50 +138,47 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => | ... | @@ -138,50 +138,47 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => |
| 138 | }), | 138 | }), |
| 139 | ); | 139 | ); |
| 140 | 140 | ||
| 141 | it.effect("uses gh json listing for non-open change request state queries", () => | 141 | it.effect("lists change request history through the batched head lookup", () => |
| 142 | Effect.gen(function* () { | 142 | Effect.gen(function* () { |
| 143 | let executeArgs: ReadonlyArray<string> = []; | 143 | let lookup: Parameters<GitHubCli.GitHubCli["Service"]["listPullRequestsByHead"]>[0] | null = |
| 144 | null; | ||
| 144 | const provider = yield* makeProvider({ | 145 | const provider = yield* makeProvider({ |
| 145 | execute: (input) => { | 146 | listPullRequestsByHead: (input) => { |
| 146 | executeArgs = input.args; | 147 | lookup = input; |
| 147 | return Effect.succeed( | 148 | return Effect.succeed([ |
| 148 | processResult( | 149 | { |
| 149 | JSON.stringify([ | 150 | number: 7, |
| 150 | { | 151 | title: "Merged work", |
| 151 | number: 7, | 152 | url: "https://enterprise.test/acme/web/pull/7", |
| 152 | title: "Merged work", | 153 | baseRefName: "main", |
| 153 | url: "https://github.com/pingdotgg/t3code/pull/7", | 154 | headRefName: "feature/merged", |
| 154 | baseRefName: "main", | 155 | state: "merged", |
| 155 | headRefName: "feature/merged", | 156 | mergedAt: "2026-01-01T00:00:00Z", |
| 156 | state: "merged", | 157 | updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), |
| 157 | mergedAt: "2026-01-01T00:00:00Z", | 158 | }, |
| 158 | updatedAt: "2026-01-02T00:00:00.000Z", | 159 | ]); |
| 159 | }, | ||
| 160 | ]), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | }, | 160 | }, |
| 164 | }); | 161 | }); |
| 165 | 162 | ||
| 166 | const changeRequests = yield* provider.listChangeRequests({ | 163 | const changeRequests = yield* provider.listChangeRequests({ |
| 167 | cwd: "/repo", | 164 | cwd: "/repo", |
| 165 | context: { | ||
| 166 | provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, | ||
| 167 | remoteName: "origin", | ||
| 168 | remoteUrl: "https://enterprise.test/acme/web.git", | ||
| 169 | }, | ||
| 168 | headSelector: "feature/merged", | 170 | headSelector: "feature/merged", |
| 169 | state: "all", | 171 | state: "all", |
| 170 | limit: 10, | 172 | limit: 10, |
| 171 | }); | 173 | }); |
| 172 | 174 | ||
| 173 | assert.deepStrictEqual(executeArgs, [ | 175 | assert.deepStrictEqual(lookup, { |
| 174 | "pr", | 176 | cwd: "/repo", |
| 175 | "list", | 177 | headSelector: "feature/merged", |
| 176 | "--head", | 178 | state: "all", |
| 177 | "feature/merged", | 179 | limit: 10, |
| 178 | "--state", | 180 | rateLimitHost: "enterprise.test", |
| 179 | "all", | 181 | }); |
| 180 | "--limit", | ||
| 181 | "10", | ||
| 182 | "--json", | ||
| 183 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 184 | ]); | ||
| 185 | assert.strictEqual(changeRequests[0]?.provider, "github"); | 182 | assert.strictEqual(changeRequests[0]?.provider, "github"); |
| 186 | assert.strictEqual(changeRequests[0]?.state, "merged"); | 183 | assert.strictEqual(changeRequests[0]?.state, "merged"); |
| 187 | assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); | 184 | assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); |
| ... | @@ -192,23 +189,6 @@ it.effect("uses gh json listing for non-open change request state queries", () = | ... | @@ -192,23 +189,6 @@ it.effect("uses gh json listing for non-open change request state queries", () = |
| 192 | }), | 189 | }), |
| 193 | ); | 190 | ); |
| 194 | 191 | ||
| 195 | it.effect("treats empty non-open change request listing output as no results", () => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | const provider = yield* makeProvider({ | ||
| 198 | execute: () => Effect.succeed(processResult("")), | ||
| 199 | }); | ||
| 200 | |||
| 201 | const changeRequests = yield* provider.listChangeRequests({ | ||
| 202 | cwd: "/repo", | ||
| 203 | headSelector: "feature/empty", | ||
| 204 | state: "all", | ||
| 205 | limit: 10, | ||
| 206 | }); | ||
| 207 | |||
| 208 | assert.deepStrictEqual(changeRequests, []); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("creates GitHub PRs through provider-neutral input names", () => | 192 | it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 213 | Effect.gen(function* () { | 193 | Effect.gen(function* () { |
| 214 | let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null = | 194 | let createInput: Parameters<GitHubCli.GitHubCli["Service"]["createPullRequest"]>[0] | null = |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+16-53| ... | @@ -2,16 +2,10 @@ import * as Schema from "effect/Schema"; | ... | @@ -2,16 +2,10 @@ import * as Schema from "effect/Schema"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as Result from "effect/Result"; | 5 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; |
| 6 | import { | ||
| 7 | SourceControlProviderError, | ||
| 8 | type ChangeRequest, | ||
| 9 | type ChangeRequestState, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | 6 | ||
| 12 | import * as GitHubCli from "./GitHubCli.ts"; | 7 | import * as GitHubCli from "./GitHubCli.ts"; |
| 13 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | 8 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 14 | import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; | ||
| 15 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 9 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 16 | import { | 10 | import { |
| 17 | combinedAuthOutput, | 11 | combinedAuthOutput, |
| ... | @@ -151,59 +145,28 @@ export const make = Effect.gen(function* () { | ... | @@ -151,59 +145,28 @@ export const make = Effect.gen(function* () { |
| 151 | ); | 145 | ); |
| 152 | } | 146 | } |
| 153 | 147 | ||
| 154 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 155 | return github | 148 | return github |
| 156 | .execute({ | 149 | .listPullRequestsByHead({ |
| 157 | cwd: input.cwd, | 150 | cwd: input.cwd, |
| 151 | headSelector: input.headSelector, | ||
| 152 | state: input.state, | ||
| 153 | limit: input.limit ?? 20, | ||
| 158 | ...(input.context === undefined | 154 | ...(input.context === undefined |
| 159 | ? {} | 155 | ? {} |
| 160 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), | 156 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), |
| 161 | args: [ | ||
| 162 | "pr", | ||
| 163 | "list", | ||
| 164 | "--head", | ||
| 165 | input.headSelector, | ||
| 166 | "--state", | ||
| 167 | stateArg, | ||
| 168 | "--limit", | ||
| 169 | String(input.limit ?? 20), | ||
| 170 | "--json", | ||
| 171 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 172 | ], | ||
| 173 | }) | 157 | }) |
| 174 | .pipe( | 158 | .pipe( |
| 175 | Effect.flatMap((result) => { | 159 | Effect.map((items) => |
| 176 | const raw = result.stdout.trim(); | 160 | items.map(({ updatedAt, ...summary }) => ({ |
| 177 | if (raw.length === 0) { | 161 | ...toChangeRequest({ |
| 178 | return Effect.succeed([]); | 162 | ...summary, |
| 179 | } | 163 | ...(Option.isSome(updatedAt) |
| 180 | return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | 164 | ? { updatedAt: DateTime.formatIso(updatedAt.value) } |
| 181 | Effect.flatMap((decoded) => | 165 | : {}), |
| 182 | Result.isSuccess(decoded) | 166 | }), |
| 183 | ? Effect.succeed( | 167 | updatedAt, |
| 184 | decoded.success.map((item) => { | 168 | })), |
| 185 | const { updatedAt, ...summary } = item; | 169 | ), |
| 186 | return { | ||
| 187 | ...toChangeRequest({ | ||
| 188 | ...summary, | ||
| 189 | ...(Option.isSome(updatedAt) | ||
| 190 | ? { updatedAt: DateTime.formatIso(updatedAt.value) } | ||
| 191 | : {}), | ||
| 192 | }), | ||
| 193 | updatedAt, | ||
| 194 | }; | ||
| 195 | }), | ||
| 196 | ) | ||
| 197 | : Effect.fail( | ||
| 198 | new GitHubCli.GitHubChangeRequestListDecodeError({ | ||
| 199 | command: "gh", | ||
| 200 | cwd: input.cwd, | ||
| 201 | cause: decoded.failure, | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | }), | ||
| 207 | Effect.mapError( | 170 | Effect.mapError( |
| 208 | (error) => | 171 | (error) => |
| 209 | new SourceControlProviderError({ | 172 | new SourceControlProviderError({ |
apps/server/src/sourceControl/gitHubPullRequests.ts+18-13| ... | @@ -114,25 +114,30 @@ const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown | ... | @@ -114,25 +114,30 @@ const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown |
| 114 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | 114 | const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); |
| 115 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | 115 | const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); |
| 116 | 116 | ||
| 117 | /** | ||
| 118 | * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is | ||
| 119 | * skipped, so one malformed pull request cannot hide the rest. | ||
| 120 | */ | ||
| 121 | export function decodeGitHubPullRequestEntries( | ||
| 122 | entries: ReadonlyArray<unknown>, | ||
| 123 | ): ReadonlyArray<NormalizedGitHubPullRequestRecord> { | ||
| 124 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | ||
| 125 | for (const entry of entries) { | ||
| 126 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | ||
| 127 | if (Exit.isSuccess(decodedEntry)) { | ||
| 128 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | return pullRequests; | ||
| 132 | } | ||
| 133 | |||
| 117 | export function decodeGitHubPullRequestListJson( | 134 | export function decodeGitHubPullRequestListJson( |
| 118 | raw: string, | 135 | raw: string, |
| 119 | ): Result.Result< | 136 | ): Result.Result< |
| 120 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | 137 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, |
| 121 | Cause.Cause<Schema.SchemaError> | 138 | Cause.Cause<Schema.SchemaError> |
| 122 | > { | 139 | > { |
| 123 | const result = decodeGitHubPullRequestList(raw); | 140 | return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); |
| 124 | if (Result.isSuccess(result)) { | ||
| 125 | const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | ||
| 126 | for (const entry of result.success) { | ||
| 127 | const decodedEntry = decodeGitHubPullRequestEntry(entry); | ||
| 128 | if (Exit.isFailure(decodedEntry)) { | ||
| 129 | continue; | ||
| 130 | } | ||
| 131 | pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | ||
| 132 | } | ||
| 133 | return Result.succeed(pullRequests); | ||
| 134 | } | ||
| 135 | return Result.fail(result.failure); | ||
| 136 | } | 141 | } |
| 137 | 142 | ||
| 138 | export function decodeGitHubPullRequestJson( | 143 | export function decodeGitHubPullRequestJson( |
packages/contracts/src/pullRequest.ts+2| ... | @@ -747,6 +747,8 @@ export const PullRequestSummary = Schema.Struct({ | ... | @@ -747,6 +747,8 @@ export const PullRequestSummary = Schema.Struct({ |
| 747 | reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), | 747 | reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)), |
| 748 | checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), | 748 | checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)), |
| 749 | mergeability: Schema.optional(PullRequestMergeability), | 749 | mergeability: Schema.optional(PullRequestMergeability), |
| 750 | /** Null when the host says the pull request is in no stack; absent when the read did not ask. */ | ||
| 751 | stack: Schema.optional(Schema.NullOr(PullRequestStackMembership)), | ||
| 750 | }); | 752 | }); |
| 751 | export type PullRequestSummary = typeof PullRequestSummary.Type; | 753 | export type PullRequestSummary = typeof PullRequestSummary.Type; |
| 752 | 754 |