| author | |
| committer | |
| log | e84f23a97d587e0e58215f1fba2eab12717bec28 |
| tree | f767a237d2aa35b0ff8e83a8012786c1d9f895f5 |
| parent | 8ebb6112377a110afb956ac01fb44fee68f5709c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>20 files changed, 1632 insertions(+), 471 deletions(-)
apps/server/scripts/measure-pr-preview.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 | ||
| 2 | // Numbers form a session with shared repository-permission caches. Browser and | ||
| 3 | // service caches are excluded. Uses real GitHub reads, without a server or database. | ||
| 4 | // CLI-generated GraphQL | ||
| 5 | // queries are replayed with rateLimit.cost, outside the timed section, to measure | ||
| 6 | // their cost without confusing other applications' traffic with this process's. | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Console from "effect/Console"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | |||
| 13 | import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; | ||
| 14 | import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; | ||
| 15 | import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; | ||
| 16 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | ||
| 17 | |||
| 18 | const [repository, ...numbers] = process.argv.slice(2); | ||
| 19 | if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { | ||
| 20 | throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); | ||
| 21 | } | ||
| 22 | |||
| 23 | type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string }; | ||
| 24 | const reads: Read[] = []; | ||
| 25 | const measuredProcess = Layer.effect( | ||
| 26 | VcsProcess.VcsProcess, | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const vcs = yield* VcsProcess.VcsProcess; | ||
| 29 | return VcsProcess.VcsProcess.of({ | ||
| 30 | run: (input) => | ||
| 31 | vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe( | ||
| 32 | Effect.tap((output) => | ||
| 33 | Effect.sync(() => { | ||
| 34 | const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length; | ||
| 35 | const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length; | ||
| 36 | const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1]; | ||
| 37 | reads.push({ | ||
| 38 | graphqlRequests, | ||
| 39 | restRequests: requests - graphqlRequests, | ||
| 40 | cost: Number(cost ?? 0), | ||
| 41 | debug: output.stderr, | ||
| 42 | }); | ||
| 43 | }), | ||
| 44 | ), | ||
| 45 | ), | ||
| 46 | }); | ||
| 47 | }), | ||
| 48 | ).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)); | ||
| 49 | |||
| 50 | const services = GitHubPullRequestCli.layer.pipe( | ||
| 51 | Layer.provide(GitHubCli.layer), | ||
| 52 | Layer.provideMerge(measuredProcess), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 56 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 57 | const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) { | ||
| 58 | // The REST quota probe synthesizes rateLimit.cost for budget admission. | ||
| 59 | // It does not spend GraphQL points. | ||
| 60 | if (!read.graphqlRequests) return 0; | ||
| 61 | if (read.cost) return read.cost; | ||
| 62 | const vcs = yield* VcsProcess.VcsProcess; | ||
| 63 | let cost = 0; | ||
| 64 | let found = 0; | ||
| 65 | for (const match of read.debug.matchAll( | ||
| 66 | /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g, | ||
| 67 | )) { | ||
| 68 | const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }"); | ||
| 69 | const output = yield* vcs.run({ | ||
| 70 | operation: "measurePrPreview.cost", | ||
| 71 | command: "gh", | ||
| 72 | cwd: process.cwd(), | ||
| 73 | args: [ | ||
| 74 | "api", | ||
| 75 | "graphql", | ||
| 76 | "--hostname", | ||
| 77 | "github.com", | ||
| 78 | "--input", | ||
| 79 | "-", | ||
| 80 | "--jq", | ||
| 81 | ".data.rateLimit.cost", | ||
| 82 | ], | ||
| 83 | stdin: encodeJson({ query, variables: decodeJson(match[2]!) }), | ||
| 84 | env: { GH_DEBUG: "" }, | ||
| 85 | }); | ||
| 86 | cost += Number(output.stdout.trim()); | ||
| 87 | found++; | ||
| 88 | } | ||
| 89 | if (found !== read.graphqlRequests) | ||
| 90 | throw new Error("Could not account for every GraphQL request"); | ||
| 91 | return cost; | ||
| 92 | }); | ||
| 93 | |||
| 94 | for (const mode of ["detail", "preview"] as const) { | ||
| 95 | // Start each side cold, then retain the caches shared across PRs in one session. | ||
| 96 | const rows = await Effect.runPromise( | ||
| 97 | Effect.scoped( | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const provider = yield* GitHubPullRequestProvider.make; | ||
| 100 | return yield* Effect.forEach(numbers.map(Number), (number) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const input = { cwd: process.cwd(), repository, host: "github.com", number }; | ||
| 103 | reads.length = 0; | ||
| 104 | const start = performance.now(); | ||
| 105 | const value = yield* mode === "detail" | ||
| 106 | ? provider.getChangeRequest(input) | ||
| 107 | : provider.getChangeRequestPreview!(input); | ||
| 108 | const elapsedMs = Math.round(performance.now() - start); | ||
| 109 | const measured = [...reads]; | ||
| 110 | const costs = yield* Effect.forEach(measured, graphqlCost); | ||
| 111 | const points = costs.reduce((total, cost) => total + cost, 0); | ||
| 112 | return { | ||
| 113 | repository, | ||
| 114 | number, | ||
| 115 | mode, | ||
| 116 | state: value.state, | ||
| 117 | elapsedMs, | ||
| 118 | points, | ||
| 119 | graphqlRequests: measured.reduce((total, read) => total + read.graphqlRequests, 0), | ||
| 120 | restRequests: measured.reduce((total, read) => total + read.restRequests, 0), | ||
| 121 | }; | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | }), | ||
| 125 | ).pipe(Effect.provide(services)), | ||
| 126 | ); | ||
| 127 | for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row))); | ||
| 128 | } | ||
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -73,6 +73,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -73,6 +73,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 73 | [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, | 73 | [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, |
| 74 | [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, | 74 | [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, |
| 75 | [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, | 75 | [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, |
| 76 | [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, | ||
| 76 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, | 77 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, |
| 77 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, | 78 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
| 78 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, | 79 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+459-91| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Fiber from "effect/Fiber"; | 5 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| ... | @@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; | ... | @@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 16 | 17 | ||
| 17 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 18 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 18 | 19 | ||
| 20 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ | ||
| 21 | data: { | ||
| 22 | repository: { | ||
| 23 | mergeCommitAllowed: true, | ||
| 24 | squashMergeAllowed: false, | ||
| 25 | rebaseMergeAllowed: true, | ||
| 26 | viewerPermission: "WRITE", | ||
| 27 | pullRequest: { | ||
| 28 | number: 7, | ||
| 29 | title: "Pull request 7", | ||
| 30 | url: "https://github.com/acme/web/pull/7", | ||
| 31 | headRefName: "feature", | ||
| 32 | baseRefName: "main", | ||
| 33 | headRefOid: "abc123", | ||
| 34 | state: "OPEN", | ||
| 35 | createdAt: "2026-07-01T00:00:00Z", | ||
| 36 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 37 | viewerCanUpdate: true, | ||
| 38 | viewerDidAuthor: false, | ||
| 39 | viewerCanUpdateBranch: true, | ||
| 40 | baseRef: { compare: { behindBy: 2 } }, | ||
| 41 | reviewRequests: { nodes: [] }, | ||
| 42 | labels: { nodes: [] }, | ||
| 43 | commits: { nodes: [] }, | ||
| 44 | ...pullRequest, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 19 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); | 50 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); |
| 20 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => | 51 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => |
| 21 | Effect.succeed(output('{"data":{}}')), | 52 | Effect.succeed(output('{"data":{}}')), |
| ... | @@ -33,7 +64,7 @@ const layer = it.layer( | ... | @@ -33,7 +64,7 @@ const layer = it.layer( |
| 33 | getPullRequest: mockedGetPullRequest, | 64 | getPullRequest: mockedGetPullRequest, |
| 34 | }), | 65 | }), |
| 35 | ), | 66 | ), |
| 36 | Layer.provide(GitHubGraphQlBudget.layer), | 67 | Layer.provideMerge(GitHubGraphQlBudget.layer), |
| 37 | ), | 68 | ), |
| 38 | ); | 69 | ); |
| 39 | 70 | ||
| ... | @@ -255,6 +286,118 @@ it.effect( | ... | @@ -255,6 +286,118 @@ it.effect( |
| 255 | ); | 286 | ); |
| 256 | 287 | ||
| 257 | layer("GitHubPullRequestCli.layer", (it) => { | 288 | layer("GitHubPullRequestCli.layer", (it) => { |
| 289 | it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => | ||
| 290 | Effect.gen(function* () { | ||
| 291 | const now = yield* Clock.currentTimeMillis; | ||
| 292 | const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; | ||
| 293 | const resetAt = "2099-08-13T14:00:00Z"; | ||
| 294 | yield* budget.observe( | ||
| 295 | "preview-budget.example", | ||
| 296 | encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), | ||
| 297 | ); | ||
| 298 | mockedExecute.mockReturnValue( | ||
| 299 | Effect.succeed( | ||
| 300 | output( | ||
| 301 | encodeJson({ | ||
| 302 | data: { | ||
| 303 | repository: { | ||
| 304 | pullRequest: { | ||
| 305 | number: 7, | ||
| 306 | title: "Fast previews", | ||
| 307 | url: "https://preview-budget.example/acme/web/pull/7", | ||
| 308 | state: "OPEN", | ||
| 309 | isDraft: false, | ||
| 310 | createdAt: "2026-07-01T00:00:00Z", | ||
| 311 | author: null, | ||
| 312 | }, | ||
| 313 | }, | ||
| 314 | rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, | ||
| 315 | }, | ||
| 316 | }), | ||
| 317 | ), | ||
| 318 | ), | ||
| 319 | ); | ||
| 320 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 321 | const input = { | ||
| 322 | cwd: "/w", | ||
| 323 | repository: "acme/web", | ||
| 324 | host: "preview-budget.example", | ||
| 325 | number: 7, | ||
| 326 | }; | ||
| 327 | const results = yield* Effect.all( | ||
| 328 | Array.from({ length: 20 }, (_, index) => | ||
| 329 | cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), | ||
| 330 | ), | ||
| 331 | { concurrency: "unbounded" }, | ||
| 332 | ); | ||
| 333 | expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); | ||
| 334 | for (const result of results) { | ||
| 335 | if (result._tag === "Failure") { | ||
| 336 | expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); | ||
| 337 | } | ||
| 338 | } | ||
| 339 | expect(mockedExecute).toHaveBeenCalledTimes(1); | ||
| 340 | yield* TestClock.setTime(Date.parse(resetAt)); | ||
| 341 | yield* cli.getPullRequestPreview(input); | ||
| 342 | expect(mockedExecute).toHaveBeenCalledTimes(2); | ||
| 343 | yield* TestClock.setTime(now); | ||
| 344 | }), | ||
| 345 | ); | ||
| 346 | |||
| 347 | it.effect("loads the complete hover card with one GraphQL request", () => | ||
| 348 | Effect.gen(function* () { | ||
| 349 | mockedExecute.mockReturnValueOnce( | ||
| 350 | Effect.succeed( | ||
| 351 | output( | ||
| 352 | encodeJson({ | ||
| 353 | data: { | ||
| 354 | repository: { | ||
| 355 | pullRequest: { | ||
| 356 | number: 7, | ||
| 357 | title: "Fast previews", | ||
| 358 | url: "https://github.example/acme/web/pull/7", | ||
| 359 | state: "MERGED", | ||
| 360 | isDraft: false, | ||
| 361 | createdAt: "2026-07-01T00:00:00Z", | ||
| 362 | author: { | ||
| 363 | login: "octocat", | ||
| 364 | name: "Octo Cat", | ||
| 365 | avatarUrl: "https://github.example/avatar.png", | ||
| 366 | }, | ||
| 367 | }, | ||
| 368 | }, | ||
| 369 | }, | ||
| 370 | }), | ||
| 371 | ), | ||
| 372 | ), | ||
| 373 | ); | ||
| 374 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 375 | const preview = yield* cli.getPullRequestPreview({ | ||
| 376 | cwd: "/w", | ||
| 377 | repository: "acme/web", | ||
| 378 | host: "github.example", | ||
| 379 | number: 7, | ||
| 380 | }); | ||
| 381 | expect(preview).toEqual({ | ||
| 382 | number: 7, | ||
| 383 | title: "Fast previews", | ||
| 384 | url: "https://github.example/acme/web/pull/7", | ||
| 385 | state: "merged", | ||
| 386 | isDraft: false, | ||
| 387 | createdAt: "2026-07-01T00:00:00Z", | ||
| 388 | author: { | ||
| 389 | login: "octocat", | ||
| 390 | name: "Octo Cat", | ||
| 391 | avatarUrl: "https://github.example/avatar.png", | ||
| 392 | }, | ||
| 393 | }); | ||
| 394 | expect(mockedExecute).toHaveBeenCalledTimes(1); | ||
| 395 | expect(callAt(0).args).toEqual( | ||
| 396 | expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]), | ||
| 397 | ); | ||
| 398 | }), | ||
| 399 | ); | ||
| 400 | |||
| 258 | it.effect("coalesces concurrent identity verification for the same host and credential", () => | 401 | it.effect("coalesces concurrent identity verification for the same host and credential", () => |
| 259 | Effect.gen(function* () { | 402 | Effect.gen(function* () { |
| 260 | mockedExecute.mockImplementation((input) => | 403 | mockedExecute.mockImplementation((input) => |
| ... | @@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1741 | mockedExecute.mockReturnValueOnce( | 1884 | mockedExecute.mockReturnValueOnce( |
| 1742 | Effect.succeed( | 1885 | Effect.succeed( |
| 1743 | output( | 1886 | output( |
| 1744 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 1887 | encodeJson( |
| 1745 | JSON.stringify({ | 1888 | coreResponse({ |
| 1746 | number: 7, | 1889 | number: 7, |
| 1747 | title: "Pull request 7", | 1890 | title: "Pull request 7", |
| 1748 | url: "https://github.com/acme/web/pull/7", | 1891 | url: "https://github.com/acme/web/pull/7", |
| 1749 | headRefName: "feat/page", | 1892 | headRefName: "feat/page", |
| 1750 | headRefOid: "abc123", | 1893 | headRefOid: "abc123", |
| 1751 | isCrossRepository: false, | 1894 | isCrossRepository: false, |
| 1752 | headRepositoryOwner: { login: "acme" }, | 1895 | headRepositoryOwner: { login: "acme" }, |
| 1753 | baseRefName: "main", | 1896 | baseRefName: "main", |
| 1754 | createdAt: "2026-07-01T00:00:00Z", | 1897 | createdAt: "2026-07-01T00:00:00Z", |
| 1755 | updatedAt: "2026-07-02T00:00:00Z", | 1898 | updatedAt: "2026-07-02T00:00:00Z", |
| 1756 | }), | 1899 | }), |
| 1900 | ), | ||
| 1757 | ), | 1901 | ), |
| 1758 | ), | 1902 | ), |
| 1759 | ); | 1903 | ); |
| ... | @@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1774 | it.effect("finds and approves every workflow waiting on a maintainer", () => | 1918 | it.effect("finds and approves every workflow waiting on a maintainer", () => |
| 1775 | Effect.gen(function* () { | 1919 | Effect.gen(function* () { |
| 1776 | const detail = output( | 1920 | const detail = output( |
| 1777 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 1921 | encodeJson( |
| 1778 | JSON.stringify({ | 1922 | coreResponse({ |
| 1779 | number: 7, | 1923 | number: 7, |
| 1780 | title: "Pull request 7", | 1924 | title: "Pull request 7", |
| 1781 | url: "https://github.com/acme/web/pull/7", | 1925 | url: "https://github.com/acme/web/pull/7", |
| 1782 | headRefName: "feat/page", | 1926 | headRefName: "feat/page", |
| 1783 | headRefOid: "abc123", | 1927 | headRefOid: "abc123", |
| 1784 | isCrossRepository: true, | 1928 | isCrossRepository: true, |
| 1785 | headRepositoryOwner: { login: "octocat" }, | 1929 | headRepositoryOwner: { login: "octocat" }, |
| 1786 | baseRefName: "main", | 1930 | baseRefName: "main", |
| 1787 | createdAt: "2026-07-01T00:00:00Z", | 1931 | createdAt: "2026-07-01T00:00:00Z", |
| 1788 | updatedAt: "2026-07-02T00:00:00Z", | 1932 | updatedAt: "2026-07-02T00:00:00Z", |
| 1789 | }), | 1933 | }), |
| 1934 | ), | ||
| 1790 | ); | 1935 | ); |
| 1791 | const heads = output( | 1936 | const heads = output( |
| 1792 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 1937 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. |
| ... | @@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1902 | updatedAt: "2026-07-02T00:00:00Z", | 2047 | updatedAt: "2026-07-02T00:00:00Z", |
| 1903 | }; | 2048 | }; |
| 1904 | for (const value of [ | 2049 | for (const value of [ |
| 1905 | detail, | 2050 | coreResponse(detail), |
| 1906 | [ | 2051 | [ |
| 1907 | { | 2052 | { |
| 1908 | number: 7, | 2053 | number: 7, |
| ... | @@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1912 | }, | 2057 | }, |
| 1913 | ], | 2058 | ], |
| 1914 | [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], | 2059 | [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], |
| 1915 | { ...detail, headRefOid: "def456" }, | 2060 | coreResponse({ ...detail, headRefOid: "def456" }), |
| 1916 | ]) { | 2061 | ]) { |
| 1917 | mockedExecute.mockReturnValueOnce( | 2062 | mockedExecute.mockReturnValueOnce( |
| 1918 | Effect.succeed( | 2063 | Effect.succeed( |
| ... | @@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1943 | }), | 2088 | }), |
| 1944 | ); | 2089 | ); |
| 1945 | 2090 | ||
| 2091 | it.effect("reads workflow runs and their pull request scope concurrently", () => | ||
| 2092 | Effect.gen(function* () { | ||
| 2093 | const headsStarted = yield* Deferred.make<void>(); | ||
| 2094 | const runsStarted = yield* Deferred.make<void>(); | ||
| 2095 | mockedExecute.mockImplementation(({ args }) => | ||
| 2096 | args[0] === "pr" | ||
| 2097 | ? Deferred.succeed(headsStarted, undefined).pipe( | ||
| 2098 | Effect.andThen(Deferred.await(runsStarted)), | ||
| 2099 | Effect.as( | ||
| 2100 | output( | ||
| 2101 | '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]', | ||
| 2102 | ), | ||
| 2103 | ), | ||
| 2104 | ) | ||
| 2105 | : Deferred.succeed(runsStarted, undefined).pipe( | ||
| 2106 | Effect.andThen(Deferred.await(headsStarted)), | ||
| 2107 | Effect.as( | ||
| 2108 | output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'), | ||
| 2109 | ), | ||
| 2110 | ), | ||
| 2111 | ); | ||
| 2112 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 2113 | |||
| 2114 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ | ||
| 2115 | cwd: "/w", | ||
| 2116 | repository: "acme/web", | ||
| 2117 | host: "github.com", | ||
| 2118 | number: 7, | ||
| 2119 | headSha: "abc123", | ||
| 2120 | headBranch: "feat/page", | ||
| 2121 | headRepositoryOwner: "octocat", | ||
| 2122 | isCrossRepository: true, | ||
| 2123 | }); | ||
| 2124 | |||
| 2125 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); | ||
| 2126 | }), | ||
| 2127 | ); | ||
| 2128 | |||
| 1946 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => | 2129 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => |
| 1947 | Effect.gen(function* () { | 2130 | Effect.gen(function* () { |
| 2131 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | ||
| 1948 | mockedExecute.mockReturnValueOnce( | 2132 | mockedExecute.mockReturnValueOnce( |
| 1949 | Effect.succeed( | 2133 | Effect.succeed( |
| 1950 | output( | 2134 | output( |
| ... | @@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1983 | limit: 1_000, | 2167 | limit: 1_000, |
| 1984 | }); | 2168 | }); |
| 1985 | expect(error.detail).toContain("instead of uniquely matching #7"); | 2169 | expect(error.detail).toContain("instead of uniquely matching #7"); |
| 1986 | expect(mockedExecute).toHaveBeenCalledTimes(1); | 2170 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 1987 | }), | 2171 | }), |
| 1988 | ); | 2172 | ); |
| 1989 | 2173 | ||
| ... | @@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1992 | mockedExecute.mockReturnValueOnce( | 2176 | mockedExecute.mockReturnValueOnce( |
| 1993 | Effect.succeed( | 2177 | Effect.succeed( |
| 1994 | output( | 2178 | output( |
| 1995 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | 2179 | encodeJson( |
| 1996 | JSON.stringify({ | 2180 | coreResponse({ |
| 1997 | number: 7, | 2181 | number: 7, |
| 1998 | title: "Pull request 7", | 2182 | title: "Pull request 7", |
| 1999 | url: "https://github.com/acme/web/pull/7", | 2183 | url: "https://github.com/acme/web/pull/7", |
| 2000 | headRefName: "feat/page", | 2184 | headRefName: "feat/page", |
| 2001 | headRefOid: "abc123", | 2185 | headRefOid: "abc123", |
| 2002 | isCrossRepository: true, | 2186 | isCrossRepository: true, |
| 2003 | headRepositoryOwner: null, | 2187 | headRepositoryOwner: null, |
| 2004 | baseRefName: "main", | 2188 | baseRefName: "main", |
| 2005 | createdAt: "2026-07-01T00:00:00Z", | 2189 | createdAt: "2026-07-01T00:00:00Z", |
| 2006 | updatedAt: "2026-07-02T00:00:00Z", | 2190 | updatedAt: "2026-07-02T00:00:00Z", |
| 2007 | }), | 2191 | }), |
| 2192 | ), | ||
| 2008 | ), | 2193 | ), |
| 2009 | ), | 2194 | ), |
| 2010 | ); | 2195 | ); |
| ... | @@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3041 | mockedExecute.mockReturnValueOnce( | 3226 | mockedExecute.mockReturnValueOnce( |
| 3042 | Effect.succeed( | 3227 | Effect.succeed( |
| 3043 | output( | 3228 | output( |
| 3044 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3229 | encodeJson( |
| 3045 | JSON.stringify({ | 3230 | coreResponse({ |
| 3046 | number: 7, | 3231 | number: 7, |
| 3047 | title: "Progressive detail", | 3232 | title: "Progressive detail", |
| 3048 | url: "https://github.com/acme/web/pull/7", | 3233 | url: "https://github.com/acme/web/pull/7", |
| 3049 | author: { login: "octocat" }, | 3234 | author: { login: "octocat" }, |
| 3050 | headRefName: "feature", | 3235 | headRefName: "feature", |
| 3051 | baseRefName: "main", | 3236 | baseRefName: "main", |
| 3052 | createdAt: "2026-07-01T00:00:00Z", | 3237 | createdAt: "2026-07-01T00:00:00Z", |
| 3053 | updatedAt: "2026-07-02T00:00:00Z", | 3238 | updatedAt: "2026-07-02T00:00:00Z", |
| 3054 | body: "Core body", | 3239 | body: "Core body", |
| 3055 | changedFiles: 2, | 3240 | changedFiles: 2, |
| 3056 | }), | 3241 | }), |
| 3242 | ), | ||
| 3057 | ), | 3243 | ), |
| 3058 | ), | 3244 | ), |
| 3059 | ); | 3245 | ); |
| ... | @@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3083 | 3269 | ||
| 3084 | expect(detail.body).toBe("Core body"); | 3270 | expect(detail.body).toBe("Core body"); |
| 3085 | expect(activity.author?.login).toBe("octocat"); | 3271 | expect(activity.author?.login).toBe("octocat"); |
| 3086 | expect(callAt(0).args.at(-1)).toBe( | 3272 | expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); |
| 3087 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest", | 3273 | expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); |
| 3088 | ); | 3274 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ |
| 3275 | merge: true, | ||
| 3276 | squash: false, | ||
| 3277 | rebase: true, | ||
| 3278 | }); | ||
| 3279 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); | ||
| 3089 | expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); | 3280 | expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); |
| 3090 | }), | 3281 | }), |
| 3091 | ); | 3282 | ); |
| 3092 | 3283 | ||
| 3284 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => | ||
| 3285 | Effect.gen(function* () { | ||
| 3286 | mockedExecute.mockReturnValueOnce( | ||
| 3287 | Effect.succeed( | ||
| 3288 | output( | ||
| 3289 | encodeJson( | ||
| 3290 | coreResponse({ | ||
| 3291 | baseRef: null, | ||
| 3292 | reviewRequests: { | ||
| 3293 | nodes: [ | ||
| 3294 | { requestedReviewer: { login: "reviewer" } }, | ||
| 3295 | { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, | ||
| 3296 | ], | ||
| 3297 | }, | ||
| 3298 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, | ||
| 3299 | commits: { | ||
| 3300 | nodes: [ | ||
| 3301 | { | ||
| 3302 | commit: { | ||
| 3303 | statusCheckRollup: { | ||
| 3304 | contexts: { | ||
| 3305 | nodes: [ | ||
| 3306 | { | ||
| 3307 | __typename: "CheckRun", | ||
| 3308 | name: "build", | ||
| 3309 | status: "COMPLETED", | ||
| 3310 | conclusion: "SUCCESS", | ||
| 3311 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, | ||
| 3312 | }, | ||
| 3313 | { | ||
| 3314 | __typename: "CheckRun", | ||
| 3315 | name: "build", | ||
| 3316 | status: "COMPLETED", | ||
| 3317 | conclusion: "FAILURE", | ||
| 3318 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, | ||
| 3319 | }, | ||
| 3320 | ], | ||
| 3321 | pageInfo: { hasNextPage: false }, | ||
| 3322 | }, | ||
| 3323 | }, | ||
| 3324 | }, | ||
| 3325 | }, | ||
| 3326 | ], | ||
| 3327 | }, | ||
| 3328 | }), | ||
| 3329 | ), | ||
| 3330 | ), | ||
| 3331 | ), | ||
| 3332 | ); | ||
| 3333 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 3334 | const detail = yield* cli.getPullRequestDetail({ | ||
| 3335 | cwd: "/w", | ||
| 3336 | repository: "acme/web", | ||
| 3337 | host: "github.com", | ||
| 3338 | number: 7, | ||
| 3339 | }); | ||
| 3340 | expect(mockedExecute).toHaveBeenCalledOnce(); | ||
| 3341 | expect(detail.comparison).toBeNull(); | ||
| 3342 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); | ||
| 3343 | expect(detail.hasTeamReviewRequest).toBe(true); | ||
| 3344 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); | ||
| 3345 | expect(detail.checks).toHaveLength(2); | ||
| 3346 | expect(detail.checksState).toBe("failing"); | ||
| 3347 | }), | ||
| 3348 | ); | ||
| 3349 | |||
| 3350 | it.effect("reads every check when the combined response has another page", () => | ||
| 3351 | Effect.gen(function* () { | ||
| 3352 | const response = coreResponse({ | ||
| 3353 | commits: { | ||
| 3354 | nodes: [ | ||
| 3355 | { | ||
| 3356 | commit: { | ||
| 3357 | statusCheckRollup: { | ||
| 3358 | contexts: { | ||
| 3359 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], | ||
| 3360 | pageInfo: { hasNextPage: true }, | ||
| 3361 | }, | ||
| 3362 | }, | ||
| 3363 | }, | ||
| 3364 | }, | ||
| 3365 | ], | ||
| 3366 | }, | ||
| 3367 | }); | ||
| 3368 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | ||
| 3369 | mockedExecute.mockReturnValueOnce( | ||
| 3370 | Effect.succeed( | ||
| 3371 | output( | ||
| 3372 | encodeJson({ | ||
| 3373 | ...response.data.repository.pullRequest, | ||
| 3374 | reviewRequests: [], | ||
| 3375 | labels: [], | ||
| 3376 | statusCheckRollup: [ | ||
| 3377 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, | ||
| 3378 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, | ||
| 3379 | ], | ||
| 3380 | }), | ||
| 3381 | ), | ||
| 3382 | ), | ||
| 3383 | ); | ||
| 3384 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 3385 | const detail = yield* cli.getPullRequestDetail({ | ||
| 3386 | cwd: "/w", | ||
| 3387 | repository: "acme/web", | ||
| 3388 | host: "github.com", | ||
| 3389 | number: 7, | ||
| 3390 | }); | ||
| 3391 | expect(detail.checks).toHaveLength(2); | ||
| 3392 | expect(detail.checksState).toBe("failing"); | ||
| 3393 | expect(detail.checksTruncated).toBe(false); | ||
| 3394 | expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]); | ||
| 3395 | }), | ||
| 3396 | ); | ||
| 3397 | |||
| 3398 | it.effect("refuses to combine checks from different head revisions", () => | ||
| 3399 | Effect.gen(function* () { | ||
| 3400 | const response = coreResponse({ | ||
| 3401 | commits: { | ||
| 3402 | nodes: [ | ||
| 3403 | { | ||
| 3404 | commit: { | ||
| 3405 | statusCheckRollup: { | ||
| 3406 | contexts: { | ||
| 3407 | nodes: [], | ||
| 3408 | pageInfo: { hasNextPage: true }, | ||
| 3409 | }, | ||
| 3410 | }, | ||
| 3411 | }, | ||
| 3412 | }, | ||
| 3413 | ], | ||
| 3414 | }, | ||
| 3415 | }); | ||
| 3416 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); | ||
| 3417 | mockedExecute.mockReturnValueOnce( | ||
| 3418 | Effect.succeed( | ||
| 3419 | output( | ||
| 3420 | encodeJson({ | ||
| 3421 | ...response.data.repository.pullRequest, | ||
| 3422 | headRefOid: "new-head", | ||
| 3423 | reviewRequests: [], | ||
| 3424 | labels: [], | ||
| 3425 | statusCheckRollup: [], | ||
| 3426 | }), | ||
| 3427 | ), | ||
| 3428 | ), | ||
| 3429 | ); | ||
| 3430 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 3431 | const error = yield* Effect.flip( | ||
| 3432 | cli.getPullRequestDetail({ | ||
| 3433 | cwd: "/w", | ||
| 3434 | repository: "acme/web", | ||
| 3435 | host: "github.com", | ||
| 3436 | number: 7, | ||
| 3437 | }), | ||
| 3438 | ); | ||
| 3439 | expect(error._tag).toBe("GitHubPullRequestReadError"); | ||
| 3440 | }), | ||
| 3441 | ); | ||
| 3442 | |||
| 3443 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => | ||
| 3444 | Effect.gen(function* () { | ||
| 3445 | const response = coreResponse(); | ||
| 3446 | mockedExecute.mockReturnValue( | ||
| 3447 | Effect.succeed( | ||
| 3448 | output( | ||
| 3449 | encodeJson({ | ||
| 3450 | ...response, | ||
| 3451 | data: { | ||
| 3452 | ...response.data, | ||
| 3453 | rateLimit: { | ||
| 3454 | cost: 1, | ||
| 3455 | limit: 5000, | ||
| 3456 | remaining: 500, | ||
| 3457 | resetAt: "2099-08-13T14:00:00Z", | ||
| 3458 | }, | ||
| 3459 | }, | ||
| 3460 | }), | ||
| 3461 | ), | ||
| 3462 | ), | ||
| 3463 | ); | ||
| 3464 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 3465 | const input = { | ||
| 3466 | cwd: "/w", | ||
| 3467 | repository: "acme/web", | ||
| 3468 | host: "github.core-reserve.test", | ||
| 3469 | number: 7, | ||
| 3470 | }; | ||
| 3471 | yield* cli.getPullRequestDetail(input); | ||
| 3472 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); | ||
| 3473 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); | ||
| 3474 | expect(mockedExecute).toHaveBeenCalledOnce(); | ||
| 3475 | yield* cli | ||
| 3476 | .getPullRequestDetail(input) | ||
| 3477 | .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); | ||
| 3478 | expect(mockedExecute).toHaveBeenCalledTimes(2); | ||
| 3479 | }), | ||
| 3480 | ); | ||
| 3481 | |||
| 3093 | it.effect("fails a files page too large to read rather than calling the diff whole", () => | 3482 | it.effect("fails a files page too large to read rather than calling the diff whole", () => |
| 3094 | Effect.gen(function* () { | 3483 | Effect.gen(function* () { |
| 3095 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); | 3484 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| ... | @@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3271 | JSON.stringify({ | 3660 | JSON.stringify({ |
| 3272 | data: { | 3661 | data: { |
| 3273 | repository: { | 3662 | repository: { |
| 3663 | mergeCommitAllowed: true, | ||
| 3664 | squashMergeAllowed: false, | ||
| 3665 | rebaseMergeAllowed: true, | ||
| 3274 | viewerPermission: "READ", | 3666 | viewerPermission: "READ", |
| 3275 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | 3667 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 3276 | }, | 3668 | }, |
| ... | @@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3291 | // One request, because both answers hang off the same repository object. | 3683 | // One request, because both answers hang off the same repository object. |
| 3292 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | 3684 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3293 | expect(callAt(0).args).toContain("number=7"); | 3685 | expect(callAt(0).args).toContain("number=7"); |
| 3686 | expect(callAt(0).args.at(-1)).toContain( | ||
| 3687 | "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", | ||
| 3688 | ); | ||
| 3294 | expect(access).toEqual({ | 3689 | expect(access).toEqual({ |
| 3690 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | ||
| 3295 | canWrite: false, | 3691 | canWrite: false, |
| 3296 | canTriage: false, | 3692 | canTriage: false, |
| 3297 | canUpdate: true, | 3693 | canUpdate: true, |
| ... | @@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3433 | JSON.stringify({ | 3829 | JSON.stringify({ |
| 3434 | data: { | 3830 | data: { |
| 3435 | repository: { | 3831 | repository: { |
| 3832 | mergeCommitAllowed: true, | ||
| 3833 | squashMergeAllowed: false, | ||
| 3834 | rebaseMergeAllowed: true, | ||
| 3436 | viewerPermission: "READ", | 3835 | viewerPermission: "READ", |
| 3437 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, | 3836 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 3438 | }, | 3837 | }, |
| ... | @@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3460 | 3859 | ||
| 3461 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | 3860 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3462 | expect(access).toEqual({ | 3861 | expect(access).toEqual({ |
| 3862 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | ||
| 3463 | canWrite: false, | 3863 | canWrite: false, |
| 3464 | canTriage: false, | 3864 | canTriage: false, |
| 3465 | canUpdate: true, | 3865 | canUpdate: true, |
| ... | @@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3469 | }), | 3869 | }), |
| 3470 | ); | 3870 | ); |
| 3471 | 3871 | ||
| 3472 | it.effect("reads the viewer's role off the same call as the merge settings", () => | ||
| 3473 | Effect.gen(function* () { | ||
| 3474 | mockedExecute.mockReturnValue( | ||
| 3475 | Effect.succeed( | ||
| 3476 | output( | ||
| 3477 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 3478 | JSON.stringify({ | ||
| 3479 | mergeCommitAllowed: false, | ||
| 3480 | squashMergeAllowed: true, | ||
| 3481 | rebaseMergeAllowed: true, | ||
| 3482 | viewerPermission: "WRITE", | ||
| 3483 | }), | ||
| 3484 | ), | ||
| 3485 | ), | ||
| 3486 | ); | ||
| 3487 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 3488 | |||
| 3489 | const access = yield* cli.getRepositoryAccess({ | ||
| 3490 | cwd: "/w", | ||
| 3491 | repository: "acme/web", | ||
| 3492 | host: "github.com", | ||
| 3493 | }); | ||
| 3494 | |||
| 3495 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 3496 | expect(callAt(0).args).toContain( | ||
| 3497 | "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission", | ||
| 3498 | ); | ||
| 3499 | assert.isTrue(access.canWrite); | ||
| 3500 | expect(access.mergeCapabilities).toEqual({ merge: false, squash: true, rebase: true }); | ||
| 3501 | }), | ||
| 3502 | ); | ||
| 3503 | |||
| 3504 | it.effect("asks GitHub to review, naming the collection a request is added to", () => | 3872 | it.effect("asks GitHub to review, naming the collection a request is added to", () => |
| 3505 | Effect.gen(function* () { | 3873 | Effect.gen(function* () { |
| 3506 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | 3874 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+182-134| ... | @@ -30,6 +30,7 @@ import { | ... | @@ -30,6 +30,7 @@ import { |
| 30 | type PullRequestLabelCandidateList, | 30 | type PullRequestLabelCandidateList, |
| 31 | type PullRequestThreadCommentsResult, | 31 | type PullRequestThreadCommentsResult, |
| 32 | type PullRequestUpdateMethod, | 32 | type PullRequestUpdateMethod, |
| 33 | type PullRequestPreview, | ||
| 33 | } from "@t3tools/contracts"; | 34 | } from "@t3tools/contracts"; |
| 34 | 35 | ||
| 35 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 36 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| ... | @@ -44,6 +45,11 @@ import { | ... | @@ -44,6 +45,11 @@ import { |
| 44 | decodeActorAvatarsJson, | 45 | decodeActorAvatarsJson, |
| 45 | decodePullRequestActivityJson, | 46 | decodePullRequestActivityJson, |
| 46 | decodePullRequestDetailJson, | 47 | decodePullRequestDetailJson, |
| 48 | decodePullRequestCoreJson, | ||
| 49 | PULL_REQUEST_CORE_GRAPHQL_QUERY, | ||
| 50 | type GitHubPullRequestCore, | ||
| 51 | decodePullRequestPreviewJson, | ||
| 52 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | ||
| 47 | decodePullRequestFilesJson, | 53 | decodePullRequestFilesJson, |
| 48 | decodePullRequestFilesViewedJson, | 54 | decodePullRequestFilesViewedJson, |
| 49 | decodePullRequestHeadsJson, | 55 | decodePullRequestHeadsJson, |
| ... | @@ -53,7 +59,6 @@ import { | ... | @@ -53,7 +59,6 @@ import { |
| 53 | decodePullRequestStacksJson, | 59 | decodePullRequestStacksJson, |
| 54 | decodePullRequestStatsJson, | 60 | decodePullRequestStatsJson, |
| 55 | decodeReactionSubjectScopeJson, | 61 | decodeReactionSubjectScopeJson, |
| 56 | decodeRepositoryAccessJson, | ||
| 57 | decodeReviewerCandidatesJson, | 62 | decodeReviewerCandidatesJson, |
| 58 | decodeLabelCandidatesJson, | 63 | decodeLabelCandidatesJson, |
| 59 | buildLabelRequestJson, | 64 | buildLabelRequestJson, |
| ... | @@ -78,7 +83,6 @@ import { | ... | @@ -78,7 +83,6 @@ import { |
| 78 | REMOVE_REACTION_GRAPHQL_MUTATION, | 83 | REMOVE_REACTION_GRAPHQL_MUTATION, |
| 79 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | 84 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, |
| 80 | gitHubReactionContent, | 85 | gitHubReactionContent, |
| 81 | REPOSITORY_ACCESS_JSON_FIELDS, | ||
| 82 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, | 86 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, |
| 83 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, | 87 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, |
| 84 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, | 88 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, |
| ... | @@ -94,7 +98,6 @@ import { | ... | @@ -94,7 +98,6 @@ import { |
| 94 | decodeViewerPermissionsJson, | 98 | decodeViewerPermissionsJson, |
| 95 | decodeWorkflowRunApprovalsJson, | 99 | decodeWorkflowRunApprovalsJson, |
| 96 | type GitHubBaseComparison, | 100 | type GitHubBaseComparison, |
| 97 | type GitHubPullRequestDetail, | ||
| 98 | type GitHubPullRequestActivity, | 101 | type GitHubPullRequestActivity, |
| 99 | type GitHubPullRequestHead, | 102 | type GitHubPullRequestHead, |
| 100 | type GitHubPullRequestListItem, | 103 | type GitHubPullRequestListItem, |
| ... | @@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service< | ... | @@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service< |
| 519 | readonly repository: string; | 522 | readonly repository: string; |
| 520 | readonly host: string; | 523 | readonly host: string; |
| 521 | readonly number: number; | 524 | readonly number: number; |
| 522 | }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>; | 525 | }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCliError>; |
| 526 | |||
| 527 | readonly getPullRequestPreview: (input: { | ||
| 528 | readonly cwd: string; | ||
| 529 | readonly repository: string; | ||
| 530 | readonly host: string; | ||
| 531 | readonly number: number; | ||
| 532 | }) => Effect.Effect< | ||
| 533 | Omit<PullRequestPreview, "projectId" | "repository">, | ||
| 534 | GitHubPullRequestCliError | ||
| 535 | >; | ||
| 523 | 536 | ||
| 524 | readonly listWorkflowRunsRequiringApproval: (input: { | 537 | readonly listWorkflowRunsRequiringApproval: (input: { |
| 525 | readonly cwd: string; | 538 | readonly cwd: string; |
| ... | @@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service< | ... | @@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service< |
| 639 | readonly cursor: string; | 652 | readonly cursor: string; |
| 640 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>; | 653 | }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestCliError>; |
| 641 | 654 | ||
| 642 | /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */ | ||
| 643 | readonly getRepositoryAccess: (input: { | ||
| 644 | readonly cwd: string; | ||
| 645 | readonly repository: string; | ||
| 646 | readonly host: string; | ||
| 647 | }) => Effect.Effect<GitHubRepositoryAccess, GitHubPullRequestCliError>; | ||
| 648 | |||
| 649 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ | 655 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ |
| 650 | readonly getViewerAccess: (input: { | 656 | readonly getViewerAccess: (input: { |
| 651 | readonly cwd: string; | 657 | readonly cwd: string; |
| ... | @@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service< | ... | @@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service< |
| 654 | readonly number: number; | 660 | readonly number: number; |
| 655 | /** Manual action checks may use the quota held back from automatic reads. */ | 661 | /** Manual action checks may use the quota held back from automatic reads. */ |
| 656 | readonly allowReserve?: boolean | undefined; | 662 | readonly allowReserve?: boolean | undefined; |
| 657 | }) => Effect.Effect<GitHubViewerAccess, GitHubPullRequestCliError>; | 663 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestCliError>; |
| 658 | 664 | ||
| 659 | /** Who this pull request may be sent to, and who it has already been sent to. */ | 665 | /** Who this pull request may be sent to, and who it has already been sent to. */ |
| 660 | readonly listReviewerCandidates: (input: { | 666 | readonly listReviewerCandidates: (input: { |
| ... | @@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () { | ... | @@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () { |
| 1514 | return { oldContents, newContents }; | 1520 | return { oldContents, newContents }; |
| 1515 | }); | 1521 | }); |
| 1516 | 1522 | ||
| 1517 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => | 1523 | const readLegacyDetail = ( |
| 1524 | input: Parameters<GitHubPullRequestCli["Service"]["getPullRequestDetail"]>[0], | ||
| 1525 | ) => | ||
| 1518 | github | 1526 | github |
| 1519 | .execute({ | 1527 | .execute({ |
| 1520 | cwd: input.cwd, | 1528 | cwd: input.cwd, |
| ... | @@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () { | ... | @@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () { |
| 1543 | }), | 1551 | }), |
| 1544 | ); | 1552 | ); |
| 1545 | 1553 | ||
| 1554 | const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { | ||
| 1555 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 1556 | return GitHubCli.AllowGitHubReserve.pipe( | ||
| 1557 | Effect.flatMap((allowReserve) => | ||
| 1558 | graphqlRead({ | ||
| 1559 | allowReserve, | ||
| 1560 | cwd: input.cwd, | ||
| 1561 | host: input.host, | ||
| 1562 | operation: "getPullRequestDetail", | ||
| 1563 | variables: [ | ||
| 1564 | ["-f", `owner=${owner}`], | ||
| 1565 | ["-f", `name=${name}`], | ||
| 1566 | ["-F", `number=${input.number}`], | ||
| 1567 | ["-f", `headRef=refs/pull/${input.number}/head`], | ||
| 1568 | ], | ||
| 1569 | query: PULL_REQUEST_CORE_GRAPHQL_QUERY, | ||
| 1570 | decode: decodePullRequestCoreJson, | ||
| 1571 | }), | ||
| 1572 | ), | ||
| 1573 | ).pipe( | ||
| 1574 | Effect.flatMap((core) => { | ||
| 1575 | if (!core.checksTruncated) return Effect.succeed(core); | ||
| 1576 | // gh already pages check contexts. Keep its complete, deduplicated result for | ||
| 1577 | // large check suites instead of letting the first 100 checks imply success. | ||
| 1578 | return readLegacyDetail(input).pipe( | ||
| 1579 | Effect.flatMap((detail) => | ||
| 1580 | detail.headSha !== core.headSha | ||
| 1581 | ? Effect.fail( | ||
| 1582 | new GitHubPullRequestReadError({ | ||
| 1583 | command: "gh", | ||
| 1584 | cwd: input.cwd, | ||
| 1585 | operation: "getPullRequestDetail", | ||
| 1586 | cause: new Error("Pull request head changed while reading checks."), | ||
| 1587 | }), | ||
| 1588 | ) | ||
| 1589 | : Effect.succeed({ | ||
| 1590 | ...core, | ||
| 1591 | checks: detail.checks, | ||
| 1592 | checksState: detail.checksState, | ||
| 1593 | checksTruncated: false, | ||
| 1594 | }), | ||
| 1595 | ), | ||
| 1596 | ); | ||
| 1597 | }), | ||
| 1598 | ); | ||
| 1599 | }; | ||
| 1600 | |||
| 1546 | const workflowApprovalLimit = 1_000; | 1601 | const workflowApprovalLimit = 1_000; |
| 1547 | const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); | 1602 | const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); |
| 1548 | const workflowApprovalReadError = (cwd: string, cause: unknown) => | 1603 | const workflowApprovalReadError = (cwd: string, cause: unknown) => |
| ... | @@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () { | ... | @@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () { |
| 1554 | }); | 1609 | }); |
| 1555 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = | 1610 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = |
| 1556 | (input) => | 1611 | (input) => |
| 1557 | github | 1612 | Effect.all( |
| 1558 | .execute({ | 1613 | [ |
| 1559 | cwd: input.cwd, | 1614 | github |
| 1560 | args: [ | 1615 | .execute({ |
| 1561 | "pr", | 1616 | cwd: input.cwd, |
| 1562 | "list", | 1617 | args: [ |
| 1563 | ...repositoryArgs(input), | 1618 | "pr", |
| 1564 | "--state", | 1619 | "list", |
| 1565 | "open", | 1620 | ...repositoryArgs(input), |
| 1566 | "--head", | 1621 | "--state", |
| 1567 | input.headBranch, | 1622 | "open", |
| 1568 | "--limit", | 1623 | "--head", |
| 1569 | workflowApprovalProbeLimit, | 1624 | input.headBranch, |
| 1570 | "--json", | 1625 | "--limit", |
| 1571 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | 1626 | workflowApprovalProbeLimit, |
| 1572 | ], | 1627 | "--json", |
| 1573 | }) | 1628 | "number,headRefOid,isCrossRepository,headRepositoryOwner", |
| 1574 | .pipe( | 1629 | ], |
| 1575 | Effect.flatMap( | 1630 | }) |
| 1576 | ( | 1631 | .pipe( |
| 1577 | result, | 1632 | Effect.flatMap( |
| 1578 | ): Effect.Effect< | 1633 | ( |
| 1579 | GitHubPullRequestHead, | 1634 | result, |
| 1580 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | 1635 | ): Effect.Effect< |
| 1581 | > => { | 1636 | GitHubPullRequestHead, |
| 1582 | const decoded = decodePullRequestHeadsJson(result.stdout.trim()); | 1637 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError |
| 1583 | if (!Result.isSuccess(decoded)) { | 1638 | > => { |
| 1584 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | 1639 | const decoded = decodePullRequestHeadsJson(result.stdout.trim()); |
| 1585 | } | 1640 | if (!Result.isSuccess(decoded)) { |
| 1586 | const exactHeads = decoded.success.filter( | 1641 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); |
| 1587 | (pullRequest) => | 1642 | } |
| 1588 | pullRequest.headSha === input.headSha && | 1643 | const exactHeads = decoded.success.filter( |
| 1589 | pullRequest.isCrossRepository === true && | 1644 | (pullRequest) => |
| 1590 | pullRequest.headRepositoryOwner?.toLowerCase() === | 1645 | pullRequest.headSha === input.headSha && |
| 1591 | input.headRepositoryOwner.toLowerCase(), | 1646 | pullRequest.isCrossRepository === true && |
| 1592 | ); | 1647 | pullRequest.headRepositoryOwner?.toLowerCase() === |
| 1593 | if (decoded.success.length > workflowApprovalLimit) { | 1648 | input.headRepositoryOwner.toLowerCase(), |
| 1594 | return Effect.fail( | 1649 | ); |
| 1595 | new GitHubWorkflowApprovalRefusedError({ | 1650 | if (decoded.success.length > workflowApprovalLimit) { |
| 1596 | command: "gh", | 1651 | return Effect.fail( |
| 1597 | cwd: input.cwd, | 1652 | new GitHubWorkflowApprovalRefusedError({ |
| 1598 | number: input.number, | 1653 | command: "gh", |
| 1599 | reason: "head-list-truncated", | 1654 | cwd: input.cwd, |
| 1600 | observedCount: decoded.success.length, | 1655 | number: input.number, |
| 1601 | limit: workflowApprovalLimit, | 1656 | reason: "head-list-truncated", |
| 1602 | }), | 1657 | observedCount: decoded.success.length, |
| 1603 | ); | 1658 | limit: workflowApprovalLimit, |
| 1604 | } | 1659 | }), |
| 1605 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | 1660 | ); |
| 1606 | return Effect.fail( | 1661 | } |
| 1607 | new GitHubWorkflowApprovalRefusedError({ | 1662 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { |
| 1608 | command: "gh", | 1663 | return Effect.fail( |
| 1609 | cwd: input.cwd, | 1664 | new GitHubWorkflowApprovalRefusedError({ |
| 1610 | number: input.number, | 1665 | command: "gh", |
| 1611 | reason: "head-not-unique", | 1666 | cwd: input.cwd, |
| 1612 | observedCount: exactHeads.length, | 1667 | number: input.number, |
| 1613 | limit: workflowApprovalLimit, | 1668 | reason: "head-not-unique", |
| 1614 | }), | 1669 | observedCount: exactHeads.length, |
| 1615 | ); | 1670 | limit: workflowApprovalLimit, |
| 1616 | } | 1671 | }), |
| 1617 | return Effect.succeed(exactHeads[0]); | 1672 | ); |
| 1618 | }, | 1673 | } |
| 1619 | ), | 1674 | return Effect.succeed(exactHeads[0]); |
| 1620 | Effect.flatMap(() => | 1675 | }, |
| 1621 | github.execute({ | 1676 | ), |
| 1677 | ), | ||
| 1678 | github | ||
| 1679 | .execute({ | ||
| 1622 | cwd: input.cwd, | 1680 | cwd: input.cwd, |
| 1623 | args: [ | 1681 | args: [ |
| 1624 | "run", | 1682 | "run", |
| ... | @@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () { | ... | @@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () { |
| 1637 | "--json", | 1695 | "--json", |
| 1638 | "databaseId,workflowName,url", | 1696 | "databaseId,workflowName,url", |
| 1639 | ], | 1697 | ], |
| 1640 | }), | 1698 | }) |
| 1641 | ), | 1699 | .pipe( |
| 1642 | Effect.flatMap( | 1700 | Effect.flatMap( |
| 1643 | ( | 1701 | ( |
| 1644 | result, | 1702 | result, |
| 1645 | ): Effect.Effect< | 1703 | ): Effect.Effect< |
| 1646 | ReadonlyArray<GitHubWorkflowRunApproval>, | 1704 | ReadonlyArray<GitHubWorkflowRunApproval>, |
| 1647 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | 1705 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError |
| 1648 | > => { | 1706 | > => { |
| 1649 | const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); | 1707 | const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); |
| 1650 | if (!Result.isSuccess(decoded)) { | 1708 | if (!Result.isSuccess(decoded)) { |
| 1651 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | 1709 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); |
| 1652 | } | 1710 | } |
| 1653 | return decoded.success.length > workflowApprovalLimit | 1711 | return decoded.success.length > workflowApprovalLimit |
| 1654 | ? Effect.fail( | 1712 | ? Effect.fail( |
| 1655 | new GitHubWorkflowApprovalRefusedError({ | 1713 | new GitHubWorkflowApprovalRefusedError({ |
| 1656 | command: "gh", | 1714 | command: "gh", |
| 1657 | cwd: input.cwd, | 1715 | cwd: input.cwd, |
| 1658 | number: input.number, | 1716 | number: input.number, |
| 1659 | reason: "run-list-truncated", | 1717 | reason: "run-list-truncated", |
| 1660 | observedCount: decoded.success.length, | 1718 | observedCount: decoded.success.length, |
| 1661 | limit: workflowApprovalLimit, | 1719 | limit: workflowApprovalLimit, |
| 1662 | }), | 1720 | }), |
| 1663 | ) | 1721 | ) |
| 1664 | : Effect.succeed(decoded.success); | 1722 | : Effect.succeed(decoded.success); |
| 1665 | }, | 1723 | }, |
| 1666 | ), | 1724 | ), |
| 1667 | ); | 1725 | ), |
| 1726 | ], | ||
| 1727 | { concurrency: 2 }, | ||
| 1728 | ).pipe(Effect.map(([, runs]) => runs)); | ||
| 1668 | 1729 | ||
| 1669 | return GitHubPullRequestCli.of({ | 1730 | return GitHubPullRequestCli.of({ |
| 1670 | withVerifiedCredential, | 1731 | withVerifiedCredential, |
| ... | @@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () { | ... | @@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () { |
| 1924 | ), | 1985 | ), |
| 1925 | 1986 | ||
| 1926 | getPullRequestDetail, | 1987 | getPullRequestDetail, |
| 1988 | getPullRequestPreview: (input) => { | ||
| 1989 | const { owner, name } = parseRepositorySelector(input.repository); | ||
| 1990 | return graphqlRead({ | ||
| 1991 | cwd: input.cwd, | ||
| 1992 | host: input.host, | ||
| 1993 | operation: "getPullRequestPreview", | ||
| 1994 | variables: [ | ||
| 1995 | ["-f", `owner=${owner}`], | ||
| 1996 | ["-f", `name=${name}`], | ||
| 1997 | ["-F", `number=${input.number}`], | ||
| 1998 | ], | ||
| 1999 | query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | ||
| 2000 | decode: decodePullRequestPreviewJson, | ||
| 2001 | }); | ||
| 2002 | }, | ||
| 1927 | listWorkflowRunsRequiringApproval, | 2003 | listWorkflowRunsRequiringApproval, |
| 1928 | 2004 | ||
| 1929 | getPullRequestStack: (input) => { | 2005 | getPullRequestStack: (input) => { |
| ... | @@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () { | ... | @@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () { |
| 2246 | }); | 2322 | }); |
| 2247 | }, | 2323 | }, |
| 2248 | 2324 | ||
| 2249 | getRepositoryAccess: (input) => | ||
| 2250 | github | ||
| 2251 | .execute({ | ||
| 2252 | cwd: input.cwd, | ||
| 2253 | args: [ | ||
| 2254 | "repo", | ||
| 2255 | "view", | ||
| 2256 | `${input.host}/${input.repository}`, | ||
| 2257 | "--json", | ||
| 2258 | REPOSITORY_ACCESS_JSON_FIELDS, | ||
| 2259 | ], | ||
| 2260 | }) | ||
| 2261 | .pipe( | ||
| 2262 | Effect.flatMap((result) => { | ||
| 2263 | const decoded = decodeRepositoryAccessJson(result.stdout.trim()); | ||
| 2264 | return Result.isSuccess(decoded) | ||
| 2265 | ? Effect.succeed(decoded.success) | ||
| 2266 | : Effect.fail( | ||
| 2267 | new GitHubPullRequestReadError({ | ||
| 2268 | command: "gh", | ||
| 2269 | cwd: input.cwd, | ||
| 2270 | operation: "getRepositoryAccess", | ||
| 2271 | cause: decoded.failure, | ||
| 2272 | }), | ||
| 2273 | ); | ||
| 2274 | }), | ||
| 2275 | ), | ||
| 2276 | |||
| 2277 | getViewerAccess: (input) => { | 2325 | getViewerAccess: (input) => { |
| 2278 | const { owner, name } = parseRepositorySelector(input.repository); | 2326 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2279 | return graphqlRead({ | 2327 | return graphqlRead({ |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+112-37| ... | @@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | ... | @@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 9 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | 9 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 10 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; | 10 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| 11 | 11 | ||
| 12 | const coreFields = { | ||
| 13 | checksTruncated: false, | ||
| 14 | comparison: { behindBy: 0, viewerCanUpdate: true }, | ||
| 15 | viewerAccess: { | ||
| 16 | canWrite: true, | ||
| 17 | canTriage: true, | ||
| 18 | canUpdate: true, | ||
| 19 | didAuthor: false, | ||
| 20 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 21 | }, | ||
| 22 | }; | ||
| 23 | |||
| 12 | it.effect("maps credential verification failures without relabeling operation failures", () => | 24 | it.effect("maps credential verification failures without relabeling operation failures", () => |
| 13 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| 14 | let verificationFails = true; | 26 | let verificationFails = true; |
| ... | @@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => { |
| 229 | }); | 241 | }); |
| 230 | }); | 242 | }); |
| 231 | 243 | ||
| 232 | it.effect("uses the small viewer-access read for core permissions", () => | 244 | it.effect("uses the permissions carried by the core read without a second request", () => |
| 233 | Effect.gen(function* () { | 245 | Effect.gen(function* () { |
| 234 | const provider = yield* make; | 246 | const provider = yield* make; |
| 235 | const detail = yield* provider.getChangeRequest({ | 247 | const detail = yield* provider.getChangeRequest({ |
| ... | @@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => { |
| 276 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | 288 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ |
| 277 | getPullRequestDetail: () => | 289 | getPullRequestDetail: () => |
| 278 | Effect.succeed({ | 290 | Effect.succeed({ |
| 291 | ...coreFields, | ||
| 292 | comparison: null, | ||
| 293 | viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, | ||
| 279 | authorId: null, | 294 | authorId: null, |
| 280 | number: 7, | 295 | number: 7, |
| 281 | title: "Pull request 7", | 296 | title: "Pull request 7", |
| ... | @@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => { |
| 285 | headRepositoryOwner: null, | 300 | headRepositoryOwner: null, |
| 286 | headBranch: "feat/page", | 301 | headBranch: "feat/page", |
| 287 | baseBranch: "main", | 302 | baseBranch: "main", |
| 288 | state: "open", | 303 | state: "open" as const, |
| 289 | isDraft: false, | 304 | isDraft: false, |
| 290 | mergeability: "mergeable", | 305 | mergeability: "mergeable" as const, |
| 291 | reviewDecision: null, | 306 | reviewDecision: null, |
| 292 | additions: 1, | 307 | additions: 1, |
| 293 | deletions: 1, | 308 | deletions: 1, |
| ... | @@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => { |
| 304 | checks: [], | 319 | checks: [], |
| 305 | comments: [], | 320 | comments: [], |
| 306 | commits: [], | 321 | commits: [], |
| 307 | }), | 322 | }).pipe( |
| 308 | getRepositoryAccess: () => | 323 | Effect.flatMap((detail) => |
| 309 | GitHubCli.PinnedGitHubCredential.pipe( | 324 | GitHubCli.PinnedGitHubCredential.pipe( |
| 310 | Effect.map((credential) => ({ | 325 | Effect.map((credential) => ({ |
| 311 | canWrite: false, | 326 | ...detail, |
| 312 | mergeCapabilities: { | 327 | viewerAccess: { |
| 313 | merge: true, | 328 | ...detail.viewerAccess, |
| 314 | squash: credential?.credentialFingerprint !== "restricted", | 329 | mergeCapabilities: { |
| 315 | rebase: true, | 330 | ...detail.viewerAccess.mergeCapabilities, |
| 316 | }, | 331 | squash: credential?.credentialFingerprint !== "restricted", |
| 317 | })), | 332 | }, |
| 333 | }, | ||
| 334 | })), | ||
| 335 | ), | ||
| 336 | ), | ||
| 318 | ), | 337 | ), |
| 319 | getViewerAccess: () => | ||
| 320 | Effect.succeed({ | ||
| 321 | canWrite: false, | ||
| 322 | canTriage: false, | ||
| 323 | canUpdate: true, | ||
| 324 | didAuthor: false, | ||
| 325 | }), | ||
| 326 | }), | 338 | }), |
| 327 | ), | 339 | ), |
| 328 | ), | 340 | ), |
| ... | @@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => { |
| 364 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | 376 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ |
| 365 | getPullRequestDetail: () => | 377 | getPullRequestDetail: () => |
| 366 | Effect.succeed({ | 378 | Effect.succeed({ |
| 379 | ...coreFields, | ||
| 367 | authorId: null, | 380 | authorId: null, |
| 368 | number: 7, | 381 | number: 7, |
| 369 | title: "Pull request 7", | 382 | title: "Pull request 7", |
| ... | @@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => { |
| 412 | ]), | 425 | ]), |
| 413 | getPullRequestBaseComparison: () => | 426 | getPullRequestBaseComparison: () => |
| 414 | Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | 427 | Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 415 | getRepositoryAccess: () => | 428 | getViewerAccess: () => |
| 416 | Effect.succeed({ | 429 | Effect.succeed({ |
| 417 | canWrite: true, | 430 | canWrite: true, |
| 431 | canTriage: true, | ||
| 432 | canUpdate: true, | ||
| 433 | didAuthor: false, | ||
| 418 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | 434 | mergeCapabilities: { merge: true, squash: true, rebase: true }, |
| 419 | }), | 435 | }), |
| 420 | getViewerAccess: () => | ||
| 421 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | ||
| 422 | }), | 436 | }), |
| 423 | ), | 437 | ), |
| 424 | ), | 438 | ), |
| ... | @@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => { |
| 426 | }); | 440 | }); |
| 427 | 441 | ||
| 428 | const openDetail = { | 442 | const openDetail = { |
| 443 | ...coreFields, | ||
| 429 | authorId: null, | 444 | authorId: null, |
| 430 | number: 7, | 445 | number: 7, |
| 431 | title: "Pull request 7", | 446 | title: "Pull request 7", |
| ... | @@ -457,6 +472,39 @@ const openDetail = { | ... | @@ -457,6 +472,39 @@ const openDetail = { |
| 457 | commits: [], | 472 | commits: [], |
| 458 | }; | 473 | }; |
| 459 | 474 | ||
| 475 | it.effect( | ||
| 476 | "uses the core comparison and permissions while preserving workflow approval checks", | ||
| 477 | () => | ||
| 478 | Effect.gen(function* () { | ||
| 479 | const provider = yield* make.pipe( | ||
| 480 | Effect.provide( | ||
| 481 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | ||
| 482 | getPullRequestDetail: () => | ||
| 483 | Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), | ||
| 484 | listWorkflowRunsRequiringApproval: () => | ||
| 485 | Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), | ||
| 486 | }), | ||
| 487 | ), | ||
| 488 | ); | ||
| 489 | |||
| 490 | const detail = yield* provider.getChangeRequest({ | ||
| 491 | cwd: "/w", | ||
| 492 | repository: "acme/web", | ||
| 493 | host: "github.com", | ||
| 494 | number: 7, | ||
| 495 | }); | ||
| 496 | |||
| 497 | expect(detail.behindBy).toBe(2); | ||
| 498 | expect(detail.workflowApprovalsRequired).toBe(1); | ||
| 499 | expect(detail.checks).toContainEqual({ | ||
| 500 | name: "tests", | ||
| 501 | status: "action-required", | ||
| 502 | description: "A maintainer must approve this workflow before it can run.", | ||
| 503 | url: "https://example.com/runs/123", | ||
| 504 | }); | ||
| 505 | }), | ||
| 506 | ); | ||
| 507 | |||
| 460 | it.effect("does not classify same-repository gates as fork workflow approvals", () => | 508 | it.effect("does not classify same-repository gates as fork workflow approvals", () => |
| 461 | Effect.gen(function* () { | 509 | Effect.gen(function* () { |
| 462 | const provider = yield* make; | 510 | const provider = yield* make; |
| ... | @@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals", | ... | @@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals", |
| 476 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | 524 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 477 | listWorkflowRunsRequiringApproval: () => | 525 | listWorkflowRunsRequiringApproval: () => |
| 478 | Effect.die("same-repository pull requests must not probe fork workflow approvals"), | 526 | Effect.die("same-repository pull requests must not probe fork workflow approvals"), |
| 479 | getRepositoryAccess: () => | 527 | getViewerAccess: () => |
| 480 | Effect.succeed({ | 528 | Effect.succeed({ |
| 481 | canWrite: true, | 529 | canWrite: true, |
| 530 | canTriage: true, | ||
| 531 | canUpdate: true, | ||
| 532 | didAuthor: false, | ||
| 482 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | 533 | mergeCapabilities: { merge: true, squash: true, rebase: true }, |
| 483 | }), | 534 | }), |
| 484 | getViewerAccess: () => | ||
| 485 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | ||
| 486 | }), | 535 | }), |
| 487 | ), | 536 | ), |
| 488 | ), | 537 | ), |
| ... | @@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => | ... | @@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => |
| 523 | limit: 1_000, | 572 | limit: 1_000, |
| 524 | }), | 573 | }), |
| 525 | ), | 574 | ), |
| 526 | getRepositoryAccess: () => | 575 | getViewerAccess: () => |
| 527 | Effect.succeed({ | 576 | Effect.succeed({ |
| 528 | canWrite: true, | 577 | canWrite: true, |
| 578 | canTriage: true, | ||
| 579 | canUpdate: true, | ||
| 580 | didAuthor: false, | ||
| 529 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | 581 | mergeCapabilities: { merge: true, squash: true, rebase: true }, |
| 530 | }), | 582 | }), |
| 531 | getViewerAccess: () => | ||
| 532 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | ||
| 533 | }), | 583 | }), |
| 534 | ), | 584 | ), |
| 535 | ), | 585 | ), |
| ... | @@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () => | ... | @@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () => |
| 562 | cause: new Error("rate limited"), | 612 | cause: new Error("rate limited"), |
| 563 | }), | 613 | }), |
| 564 | ), | 614 | ), |
| 565 | getRepositoryAccess: () => | 615 | getViewerAccess: () => |
| 566 | Effect.succeed({ | 616 | Effect.succeed({ |
| 567 | canWrite: true, | 617 | canWrite: true, |
| 618 | canTriage: true, | ||
| 619 | canUpdate: true, | ||
| 620 | didAuthor: false, | ||
| 568 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | 621 | mergeCapabilities: { merge: true, squash: true, rebase: true }, |
| 569 | }), | 622 | }), |
| 570 | getViewerAccess: () => | ||
| 571 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | ||
| 572 | }), | 623 | }), |
| 573 | ), | 624 | ), |
| 574 | ), | 625 | ), |
| ... | @@ -598,7 +649,13 @@ describe("getViewerPermissions", () => { | ... | @@ -598,7 +649,13 @@ describe("getViewerPermissions", () => { |
| 598 | getViewerAccess: () => | 649 | getViewerAccess: () => |
| 599 | Effect.sync(() => { | 650 | Effect.sync(() => { |
| 600 | accessReads++; | 651 | accessReads++; |
| 601 | return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; | 652 | return { |
| 653 | canWrite: true, | ||
| 654 | canTriage: true, | ||
| 655 | canUpdate: true, | ||
| 656 | didAuthor: false, | ||
| 657 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 658 | }; | ||
| 602 | }), | 659 | }), |
| 603 | }), | 660 | }), |
| 604 | ), | 661 | ), |
| ... | @@ -615,7 +672,13 @@ describe("getViewerPermissions", () => { | ... | @@ -615,7 +672,13 @@ describe("getViewerPermissions", () => { |
| 615 | getPullRequestDetail: () => Effect.succeed(openDetail), | 672 | getPullRequestDetail: () => Effect.succeed(openDetail), |
| 616 | getPullRequestBaseComparison: () => comparison, | 673 | getPullRequestBaseComparison: () => comparison, |
| 617 | getViewerAccess: () => | 674 | getViewerAccess: () => |
| 618 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | 675 | Effect.succeed({ |
| 676 | canWrite: true, | ||
| 677 | canTriage: true, | ||
| 678 | canUpdate: true, | ||
| 679 | didAuthor: false, | ||
| 680 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 681 | }), | ||
| 619 | }); | 682 | }); |
| 620 | 683 | ||
| 621 | it.effect("offers update-branch when the comparison grants it", () => | 684 | it.effect("offers update-branch when the comparison grants it", () => |
| ... | @@ -661,7 +724,13 @@ describe("getViewerPermissions", () => { | ... | @@ -661,7 +724,13 @@ describe("getViewerPermissions", () => { |
| 661 | getViewerAccess: (input) => | 724 | getViewerAccess: (input) => |
| 662 | Effect.sync(() => { | 725 | Effect.sync(() => { |
| 663 | viewerAllowReserve = input.allowReserve; | 726 | viewerAllowReserve = input.allowReserve; |
| 664 | return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; | 727 | return { |
| 728 | canWrite: true, | ||
| 729 | canTriage: true, | ||
| 730 | canUpdate: true, | ||
| 731 | didAuthor: false, | ||
| 732 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 733 | }; | ||
| 665 | }), | 734 | }), |
| 666 | }), | 735 | }), |
| 667 | ), | 736 | ), |
| ... | @@ -696,7 +765,13 @@ describe("getViewerPermissions", () => { | ... | @@ -696,7 +765,13 @@ describe("getViewerPermissions", () => { |
| 696 | }), | 765 | }), |
| 697 | ), | 766 | ), |
| 698 | getViewerAccess: () => | 767 | getViewerAccess: () => |
| 699 | Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), | 768 | Effect.succeed({ |
| 769 | canWrite: true, | ||
| 770 | canTriage: true, | ||
| 771 | canUpdate: true, | ||
| 772 | didAuthor: false, | ||
| 773 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 774 | }), | ||
| 700 | }), | 775 | }), |
| 701 | ), | 776 | ), |
| 702 | ), | 777 | ), |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+72-127| ... | @@ -1,7 +1,4 @@ | ... | @@ -1,7 +1,4 @@ |
| 1 | import * as Cache from "effect/Cache"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import type { | 2 | import type { |
| 6 | PullRequestActor, | 3 | PullRequestActor, |
| 7 | PullRequestCapabilities, | 4 | PullRequestCapabilities, |
| ... | @@ -11,7 +8,6 @@ import type { | ... | @@ -11,7 +8,6 @@ import type { |
| 11 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 12 | 9 | ||
| 13 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 14 | import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts"; | ||
| 15 | import { | 11 | import { |
| 16 | PullRequestProviderError, | 12 | PullRequestProviderError, |
| 17 | type PullRequestProviderFailure, | 13 | type PullRequestProviderFailure, |
| ... | @@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean => | ... | @@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean => |
| 193 | export const make = Effect.gen(function* () { | 189 | export const make = Effect.gen(function* () { |
| 194 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 190 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 195 | 191 | ||
| 196 | const repositoryAccessCache = yield* Cache.makeWith( | ||
| 197 | (key: string) => { | ||
| 198 | const [cwd, repository, host] = JSON.parse(key) as [string, string, string]; | ||
| 199 | return cli.getRepositoryAccess({ cwd, repository, host }); | ||
| 200 | }, | ||
| 201 | { | ||
| 202 | capacity: 128, | ||
| 203 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), | ||
| 204 | }, | ||
| 205 | ); | ||
| 206 | const getRepositoryAccess = (input: { | ||
| 207 | readonly cwd: string; | ||
| 208 | readonly repository: string; | ||
| 209 | readonly host: string; | ||
| 210 | }) => | ||
| 211 | PinnedGitHubCredential.pipe( | ||
| 212 | Effect.flatMap((credential) => | ||
| 213 | Cache.get( | ||
| 214 | repositoryAccessCache, | ||
| 215 | JSON.stringify([ | ||
| 216 | input.cwd, | ||
| 217 | input.repository, | ||
| 218 | input.host, | ||
| 219 | credential?.credentialFingerprint ?? null, | ||
| 220 | ]), | ||
| 221 | ), | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | |||
| 225 | const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => | 192 | const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => |
| 226 | new PullRequestProviderError({ | 193 | new PullRequestProviderError({ |
| 227 | provider: "github", | 194 | provider: "github", |
| ... | @@ -340,102 +307,80 @@ export const make = Effect.gen(function* () { | ... | @@ -340,102 +307,80 @@ export const make = Effect.gen(function* () { |
| 340 | getChangeRequestStack: (input) => | 307 | getChangeRequestStack: (input) => |
| 341 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), | 308 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), |
| 342 | 309 | ||
| 310 | getChangeRequestPreview: (input) => | ||
| 311 | cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), | ||
| 312 | |||
| 343 | getChangeRequest: (input) => | 313 | getChangeRequest: (input) => |
| 344 | Effect.all( | 314 | cli.getPullRequestDetail(input).pipe( |
| 345 | [ | 315 | Effect.flatMap((pullRequest) => { |
| 346 | cli.getPullRequestDetail(input).pipe( | 316 | // Fork workflows awaiting approval are absent from the normal check rollup. |
| 347 | Effect.flatMap((pullRequest) => | 317 | const approvals = |
| 348 | Effect.all({ | 318 | pullRequest.state !== "open" || pullRequest.isCrossRepository !== true |
| 349 | // Only an open pull request can be behind anything worth saying so about, and | 319 | ? Effect.succeed({ |
| 350 | // only one whose head repository is known can be compared at all. | 320 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 351 | comparison: | 321 | unavailable: false, |
| 352 | pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null | 322 | }) |
| 353 | ? Effect.succeed(null) | 323 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null |
| 354 | : cli | 324 | ? Effect.succeed({ |
| 355 | .getPullRequestBaseComparison({ | 325 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 356 | ...input, | 326 | unavailable: true, |
| 357 | headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, | 327 | }) |
| 358 | }) | 328 | : cli |
| 359 | .pipe(Effect.orElseSucceed(() => null)), | 329 | .listWorkflowRunsRequiringApproval({ |
| 360 | // GitHub omits a fork workflow that has not been approved from the normal check | 330 | ...input, |
| 361 | // rollup. Read the action-required runs by head revision so "all passed" cannot | 331 | headSha: pullRequest.headSha, |
| 362 | // be shown while a whole workflow is still waiting to start. | 332 | headBranch: pullRequest.headBranch, |
| 363 | workflowApprovals: | 333 | headRepositoryOwner: pullRequest.headRepositoryOwner, |
| 364 | pullRequest.state !== "open" || pullRequest.isCrossRepository !== true | 334 | isCrossRepository: true, |
| 365 | ? Effect.succeed({ | 335 | }) |
| 366 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | 336 | .pipe( |
| 367 | unavailable: false, | 337 | Effect.matchEffect({ |
| 368 | }) | 338 | onFailure: (error) => |
| 369 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null | 339 | error._tag === "GitHubCliRateLimitError" || |
| 370 | ? Effect.succeed({ | 340 | error._tag === "SourceControlRateLimitPausedError" |
| 371 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | 341 | ? Effect.fail(error) |
| 372 | unavailable: true, | 342 | : Effect.succeed({ |
| 373 | }) | 343 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 374 | : cli | 344 | unavailable: true, |
| 375 | .listWorkflowRunsRequiringApproval({ | 345 | }), |
| 376 | ...input, | 346 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), |
| 377 | headSha: pullRequest.headSha, | 347 | }), |
| 378 | headBranch: pullRequest.headBranch, | 348 | ); |
| 379 | headRepositoryOwner: pullRequest.headRepositoryOwner, | 349 | return approvals.pipe( |
| 380 | isCrossRepository: true, | 350 | Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({ |
| 381 | }) | 351 | ...pullRequest, |
| 382 | .pipe( | 352 | author: withAvatar(pullRequest.author, new Map<string, string>(), input.host), |
| 383 | Effect.matchEffect({ | 353 | checks: withWorkflowApprovals( |
| 384 | onFailure: (error) => | 354 | pullRequest.checks, |
| 385 | error._tag === "GitHubCliRateLimitError" || | 355 | workflowApprovals.runs, |
| 386 | error._tag === "SourceControlRateLimitPausedError" | 356 | workflowApprovals.unavailable, |
| 387 | ? Effect.fail(error) | 357 | ), |
| 388 | : Effect.succeed({ | 358 | ...(workflowApprovals.unavailable |
| 389 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | 359 | ? {} |
| 390 | unavailable: true, | 360 | : { workflowApprovalsRequired: workflowApprovals.runs.length }), |
| 391 | }), | 361 | reviewers: pullRequest.reviewRequestLogins.map((login) => ({ |
| 392 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), | 362 | login, |
| 393 | }), | 363 | name: null, |
| 394 | ), | 364 | avatarUrl: null, |
| 395 | }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))), | 365 | })), |
| 396 | ), | 366 | mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, |
| 397 | ), | 367 | viewerPermissions: gitHubViewerPermissions({ |
| 398 | getRepositoryAccess({ | 368 | ...pullRequest.viewerAccess, |
| 399 | cwd: input.cwd, | 369 | canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, |
| 400 | repository: input.repository, | 370 | }), |
| 401 | host: input.host, | 371 | baseComparison: |
| 402 | }), | 372 | pullRequest.comparison === null || pullRequest.comparison.behindBy === null |
| 403 | // A small permissions query replaces the deeply paginated review-thread walk on the | 373 | ? "unknown" |
| 404 | // core path. Writes ask again immediately before mutating, so this is presentation. | 374 | : pullRequest.comparison.behindBy > 0 |
| 405 | cli.getViewerAccess(input), | 375 | ? "behind" |
| 406 | ], | 376 | : "up-to-date", |
| 407 | { concurrency: 3 }, | 377 | ...(pullRequest.comparison?.behindBy == null |
| 408 | ).pipe( | 378 | ? {} |
| 379 | : { behindBy: pullRequest.comparison.behindBy }), | ||
| 380 | })), | ||
| 381 | ); | ||
| 382 | }), | ||
| 409 | Effect.mapError(fail("getChangeRequest")), | 383 | Effect.mapError(fail("getChangeRequest")), |
| 410 | Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ | ||
| 411 | ...detail.pullRequest, | ||
| 412 | author: withAvatar(detail.pullRequest.author, new Map<string, string>(), input.host), | ||
| 413 | checks: withWorkflowApprovals( | ||
| 414 | detail.pullRequest.checks, | ||
| 415 | detail.workflowApprovals.runs, | ||
| 416 | detail.workflowApprovals.unavailable, | ||
| 417 | ), | ||
| 418 | ...(detail.workflowApprovals.unavailable | ||
| 419 | ? {} | ||
| 420 | : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), | ||
| 421 | reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ | ||
| 422 | login, | ||
| 423 | name: null, | ||
| 424 | avatarUrl: null, | ||
| 425 | })), | ||
| 426 | mergeCapabilities: repository.mergeCapabilities, | ||
| 427 | viewerPermissions: gitHubViewerPermissions({ | ||
| 428 | ...viewerAccess, | ||
| 429 | canUpdateBranch: detail.comparison?.viewerCanUpdate === true, | ||
| 430 | }), | ||
| 431 | baseComparison: | ||
| 432 | detail.comparison === null || detail.comparison.behindBy === null | ||
| 433 | ? "unknown" | ||
| 434 | : detail.comparison.behindBy > 0 | ||
| 435 | ? "behind" | ||
| 436 | : "up-to-date", | ||
| 437 | ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), | ||
| 438 | })), | ||
| 439 | ), | 384 | ), |
| 440 | 385 | ||
| 441 | getChangeRequestActivity: (input) => | 386 | getChangeRequestActivity: (input) => |
apps/server/src/pullRequest/PullRequestProvider.ts+9| ... | @@ -31,6 +31,7 @@ import type { | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | PullRequestReviewerKind, | 31 | PullRequestReviewerKind, |
| 32 | PullRequestLabelCandidateList, | 32 | PullRequestLabelCandidateList, |
| 33 | PullRequestState, | 33 | PullRequestState, |
| 34 | PullRequestPreview, | ||
| 34 | PullRequestUpdateMethod, | 35 | PullRequestUpdateMethod, |
| 35 | PullRequestViewerPermissions, | 36 | PullRequestViewerPermissions, |
| 36 | SourceControlProviderKind, | 37 | SourceControlProviderKind, |
| ... | @@ -400,6 +401,14 @@ export interface PullRequestProviderApi { | ... | @@ -400,6 +401,14 @@ export interface PullRequestProviderApi { |
| 400 | input: ProviderRepositoryRef & { readonly number: number }, | 401 | input: ProviderRepositoryRef & { readonly number: number }, |
| 401 | ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>; | 402 | ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>; |
| 402 | 403 | ||
| 404 | /** Hosts without a narrow read use their existing detail response for hover cards. */ | ||
| 405 | readonly getChangeRequestPreview?: ( | ||
| 406 | input: ProviderRepositoryRef & { readonly number: number }, | ||
| 407 | ) => Effect.Effect< | ||
| 408 | Omit<PullRequestPreview, "projectId" | "repository">, | ||
| 409 | PullRequestProviderError | ||
| 410 | >; | ||
| 411 | |||
| 403 | /** | 412 | /** |
| 404 | * The cheap live fields used by linked threads. Optional because a provider without a narrow | 413 | * The cheap live fields used by linked threads. Optional because a provider without a narrow |
| 405 | * endpoint can fall back to its full detail read at the service boundary. | 414 | * endpoint can fall back to its full detail read at the service boundary. |
apps/server/src/pullRequest/PullRequestService.test.ts+218-1| ... | @@ -116,6 +116,215 @@ function hostedChangeRequest(body: string, additions = 1) { | ... | @@ -116,6 +116,215 @@ function hostedChangeRequest(body: string, additions = 1) { |
| 116 | }; | 116 | }; |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | it.effect("caches narrow previews and invalidates them after refresh or mutation", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | let reads = 0; | ||
| 122 | const service = yield* makeService({ | ||
| 123 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], | ||
| 124 | providers: [ | ||
| 125 | fakeProvider("github", { | ||
| 126 | getViewer: () => Effect.die("preview must not read the viewer"), | ||
| 127 | getChangeRequestPreview: () => | ||
| 128 | Effect.sync(() => { | ||
| 129 | reads += 1; | ||
| 130 | return changeRequest(1, "2026-07-02T00:00:00Z"); | ||
| 131 | }), | ||
| 132 | }), | ||
| 133 | ], | ||
| 134 | }); | ||
| 135 | const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 136 | const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], { | ||
| 137 | concurrency: 2, | ||
| 138 | }); | ||
| 139 | assert.deepStrictEqual(previews[0], { | ||
| 140 | ...ref, | ||
| 141 | title: "Change request 1", | ||
| 142 | url: "https://host/pull/1", | ||
| 143 | author: { login: "octocat", name: null, avatarUrl: null }, | ||
| 144 | state: "open", | ||
| 145 | isDraft: false, | ||
| 146 | createdAt: "2026-07-01T00:00:00Z", | ||
| 147 | }); | ||
| 148 | assert.strictEqual(reads, 1); | ||
| 149 | yield* service.invalidate({ reference: ref }); | ||
| 150 | yield* service.preview(ref); | ||
| 151 | assert.strictEqual(reads, 2); | ||
| 152 | yield* service.runAction({ ...ref, action: "close" }); | ||
| 153 | yield* service.preview(ref); | ||
| 154 | assert.strictEqual(reads, 3); | ||
| 155 | yield* service.refreshAfterTurn(ref.projectId); | ||
| 156 | yield* service.preview(ref); | ||
| 157 | assert.strictEqual(reads, 4); | ||
| 158 | const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" })); | ||
| 159 | assert.strictEqual(error._tag, "PullRequestOperationError"); | ||
| 160 | assert.strictEqual(reads, 4); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("keeps cached previews available and pauses uncached previews until quota resets", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | let reads = 0; | ||
| 167 | const retryAt = Date.parse("2099-08-13T14:00:00Z"); | ||
| 168 | const service = yield* makeService({ | ||
| 169 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], | ||
| 170 | providers: [ | ||
| 171 | fakeProvider("github", { | ||
| 172 | getChangeRequestPreview: ({ number }) => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | reads++; | ||
| 175 | if (reads === 2) | ||
| 176 | return yield* new PullRequestProviderError({ | ||
| 177 | provider: "github", | ||
| 178 | operation: "getChangeRequestPreview", | ||
| 179 | reason: "rate-limited", | ||
| 180 | detail: "GitHub requests are paused until the rate limit resets.", | ||
| 181 | retryAt, | ||
| 182 | }); | ||
| 183 | return changeRequest(number, "2026-07-02T00:00:00Z"); | ||
| 184 | }), | ||
| 185 | }), | ||
| 186 | ], | ||
| 187 | }); | ||
| 188 | const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 189 | yield* service.preview(ref); | ||
| 190 | const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 })); | ||
| 191 | assert.instanceOf(limited, PullRequestOperationError); | ||
| 192 | if (limited._tag !== "PullRequestOperationError") return; | ||
| 193 | assert.include(limited.detail, "paused"); | ||
| 194 | assert.strictEqual((yield* service.preview(ref)).number, 1); | ||
| 195 | for (const number of [2, 3, 4]) { | ||
| 196 | const paused = yield* Effect.flip(service.preview({ ...ref, number })); | ||
| 197 | assert.instanceOf(paused, PullRequestOperationError); | ||
| 198 | if (paused._tag !== "PullRequestOperationError") return; | ||
| 199 | assert.include(paused.detail, "paused"); | ||
| 200 | } | ||
| 201 | assert.strictEqual(reads, 2); | ||
| 202 | yield* TestClock.setTime(retryAt); | ||
| 203 | assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2); | ||
| 204 | assert.strictEqual(reads, 3); | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("reuses only unexpired detail for previews", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | let previewReads = 0; | ||
| 211 | const service = yield* makeService({ | ||
| 212 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], | ||
| 213 | providers: [ | ||
| 214 | fakeProvider("github", { | ||
| 215 | getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), | ||
| 216 | getChangeRequestPreview: () => | ||
| 217 | Effect.sync(() => { | ||
| 218 | previewReads++; | ||
| 219 | return { | ||
| 220 | ...changeRequest(1, "2026-07-02T00:00:00Z"), | ||
| 221 | title: "Updated title", | ||
| 222 | state: "closed" as const, | ||
| 223 | }; | ||
| 224 | }), | ||
| 225 | }), | ||
| 226 | ], | ||
| 227 | }); | ||
| 228 | const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 229 | yield* service.detail(ref); | ||
| 230 | assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); | ||
| 231 | assert.strictEqual(previewReads, 0); | ||
| 232 | yield* TestClock.adjust("16 seconds"); | ||
| 233 | const preview = yield* service.preview(ref); | ||
| 234 | assert.strictEqual(preview.title, "Updated title"); | ||
| 235 | assert.strictEqual(preview.state, "closed"); | ||
| 236 | assert.strictEqual(previewReads, 1); | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | |||
| 240 | it.effect("does not wait for an in-flight detail read to display a preview", () => | ||
| 241 | Effect.gen(function* () { | ||
| 242 | const detailStarted = yield* Deferred.make<void>(); | ||
| 243 | const releaseDetail = yield* Deferred.make<void>(); | ||
| 244 | const service = yield* makeService({ | ||
| 245 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], | ||
| 246 | providers: [ | ||
| 247 | fakeProvider("github", { | ||
| 248 | getChangeRequest: () => | ||
| 249 | Deferred.succeed(detailStarted, undefined).pipe( | ||
| 250 | Effect.andThen(Deferred.await(releaseDetail)), | ||
| 251 | Effect.as(hostedChangeRequest("Description")), | ||
| 252 | ), | ||
| 253 | getChangeRequestPreview: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), | ||
| 254 | }), | ||
| 255 | ], | ||
| 256 | }); | ||
| 257 | const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 258 | const detail = yield* Effect.forkChild(service.detail(ref)); | ||
| 259 | yield* Deferred.await(detailStarted); | ||
| 260 | assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); | ||
| 261 | yield* Deferred.succeed(releaseDetail, undefined); | ||
| 262 | yield* Fiber.join(detail); | ||
| 263 | }), | ||
| 264 | ); | ||
| 265 | |||
| 266 | it.effect("keeps previews warm when another project finishes a turn", () => | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const reads: string[] = []; | ||
| 269 | const service = yield* makeService({ | ||
| 270 | projects: [ | ||
| 271 | project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), | ||
| 272 | project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), | ||
| 273 | ], | ||
| 274 | providers: [ | ||
| 275 | fakeProvider("github", { | ||
| 276 | getChangeRequestPreview: (input) => | ||
| 277 | Effect.sync(() => { | ||
| 278 | reads.push(input.repository); | ||
| 279 | return changeRequest(1, "2026-07-02T00:00:00Z"); | ||
| 280 | }), | ||
| 281 | }), | ||
| 282 | ], | ||
| 283 | }); | ||
| 284 | const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 285 | const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 }; | ||
| 286 | yield* service.preview(web); | ||
| 287 | yield* service.preview(docs); | ||
| 288 | yield* service.preview({ ...web, host: "github.com" }); | ||
| 289 | assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]); | ||
| 290 | yield* service.refreshAfterTurn(web.projectId); | ||
| 291 | yield* service.preview(web); | ||
| 292 | yield* service.preview(docs); | ||
| 293 | assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]); | ||
| 294 | }), | ||
| 295 | ); | ||
| 296 | |||
| 297 | it.effect("uses full detail for hosts without a narrow preview", () => | ||
| 298 | Effect.gen(function* () { | ||
| 299 | let reads = 0; | ||
| 300 | const service = yield* makeService({ | ||
| 301 | projects: [ | ||
| 302 | project({ | ||
| 303 | id: "p1", | ||
| 304 | title: "web", | ||
| 305 | workspaceRoot: "/w", | ||
| 306 | repository: "acme/web", | ||
| 307 | provider: "gitlab", | ||
| 308 | }), | ||
| 309 | ], | ||
| 310 | providers: [ | ||
| 311 | fakeProvider("gitlab", { | ||
| 312 | getChangeRequest: () => | ||
| 313 | Effect.sync(() => { | ||
| 314 | reads += 1; | ||
| 315 | return hostedChangeRequest("Description"); | ||
| 316 | }), | ||
| 317 | }), | ||
| 318 | ], | ||
| 319 | }); | ||
| 320 | const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 321 | const preview = yield* service.preview(ref); | ||
| 322 | assert.strictEqual(preview.title, "Change request 1"); | ||
| 323 | assert.strictEqual(reads, 1); | ||
| 324 | assert.ok(!("body" in preview)); | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | |||
| 119 | function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { | 328 | function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { |
| 120 | return new PullRequestProviderError({ | 329 | return new PullRequestProviderError({ |
| 121 | provider, | 330 | provider, |
| ... | @@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after | ... | @@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after |
| 1840 | state: "open", | 2049 | state: "open", |
| 1841 | }); | 2050 | }); |
| 1842 | assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); | 2051 | assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); |
| 2052 | const preview = yield* service.preview({ | ||
| 2053 | projectId: "ssh" as ProjectId, | ||
| 2054 | host: "code.example:3000", | ||
| 2055 | repository: "team/repo", | ||
| 2056 | number: 42, | ||
| 2057 | }); | ||
| 2058 | assert.strictEqual(preview.number, 42); | ||
| 1843 | const detail = yield* service.detail({ | 2059 | const detail = yield* service.detail({ |
| 1844 | projectId: "ssh" as ProjectId, | 2060 | projectId: "ssh" as ProjectId, |
| 1845 | host: "code.example:3000", | 2061 | host: "code.example:3000", |
| ... | @@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => | ... | @@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => |
| 4071 | 4287 | ||
| 4072 | it.effect("isolates routed caches for two credentials belonging to the same account", () => | 4288 | it.effect("isolates routed caches for two credentials belonging to the same account", () => |
| 4073 | Effect.gen(function* () { | 4289 | Effect.gen(function* () { |
| 4074 | for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { | 4290 | for (const operation of ["summary", "detail", "diff", "preview", "filesViewed"] as const) { |
| 4075 | let credential = "broad"; | 4291 | let credential = "broad"; |
| 4076 | let calls = 0; | 4292 | let calls = 0; |
| 4077 | const read = () => | 4293 | const read = () => |
| ... | @@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco | ... | @@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco |
| 4109 | read().pipe( | 4325 | read().pipe( |
| 4110 | Effect.as({ patch: "private patch", truncated: false, nextCursor: null }), | 4326 | Effect.as({ patch: "private patch", truncated: false, nextCursor: null }), |
| 4111 | ), | 4327 | ), |
| 4328 | getChangeRequestPreview: read, | ||
| 4112 | }), | 4329 | }), |
| 4113 | ], | 4330 | ], |
| 4114 | }); | 4331 | }); |
apps/server/src/pullRequest/PullRequestService.ts+61| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | type PullRequestCommentInput, | 32 | type PullRequestCommentInput, |
| 33 | type PullRequestCommentUpdateInput, | 33 | type PullRequestCommentUpdateInput, |
| 34 | type PullRequestDetail, | 34 | type PullRequestDetail, |
| 35 | type PullRequestPreview, | ||
| 35 | type PullRequestDiffFileContentsInput, | 36 | type PullRequestDiffFileContentsInput, |
| 36 | type PullRequestDiffFileContentsResult, | 37 | type PullRequestDiffFileContentsResult, |
| 37 | type PullRequestDiffStat, | 38 | type PullRequestDiffStat, |
| ... | @@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service< | ... | @@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service< |
| 202 | readonly subscribeRefreshes: Stream.Stream<number>; | 203 | readonly subscribeRefreshes: Stream.Stream<number>; |
| 203 | readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>; | 204 | readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>; |
| 204 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; | 205 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; |
| 206 | readonly preview: ( | ||
| 207 | input: PullRequestRef, | ||
| 208 | ) => Effect.Effect<PullRequestPreview, PullRequestError>; | ||
| 205 | readonly activity: ( | 209 | readonly activity: ( |
| 206 | input: PullRequestRef, | 210 | input: PullRequestRef, |
| 207 | ) => Effect.Effect<PullRequestActivity, PullRequestError>; | 211 | ) => Effect.Effect<PullRequestActivity, PullRequestError>; |
| ... | @@ -546,6 +550,9 @@ function withRateLimitBackoff( | ... | @@ -546,6 +550,9 @@ function withRateLimitBackoff( |
| 546 | listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), | 550 | listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), |
| 547 | }), | 551 | }), |
| 548 | getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), | 552 | getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), |
| 553 | ...(api.getChangeRequestPreview === undefined | ||
| 554 | ? {} | ||
| 555 | : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), | ||
| 549 | ...(api.getChangeRequestSummary === undefined | 556 | ...(api.getChangeRequestSummary === undefined |
| 550 | ? {} | 557 | ? {} |
| 551 | : { | 558 | : { |
| ... | @@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () { | ... | @@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () { |
| 1679 | ), | 1686 | ), |
| 1680 | ); | 1687 | ); |
| 1681 | 1688 | ||
| 1689 | const previewFields = (value: PullRequestPreview): PullRequestPreview => ({ | ||
| 1690 | projectId: value.projectId, | ||
| 1691 | repository: value.repository, | ||
| 1692 | number: value.number, | ||
| 1693 | title: value.title, | ||
| 1694 | url: value.url, | ||
| 1695 | author: value.author, | ||
| 1696 | state: value.state, | ||
| 1697 | isDraft: value.isDraft, | ||
| 1698 | createdAt: value.createdAt, | ||
| 1699 | }); | ||
| 1700 | const previewUncached: PullRequestService["Service"]["preview"] = (input) => | ||
| 1701 | requireProject(input).pipe( | ||
| 1702 | Effect.flatMap((project) => | ||
| 1703 | (project.api.getChangeRequestPreview ?? project.api.getChangeRequest)({ | ||
| 1704 | cwd: project.project.workspaceRoot, | ||
| 1705 | repository: project.repository, | ||
| 1706 | host: project.host, | ||
| 1707 | number: input.number, | ||
| 1708 | }).pipe( | ||
| 1709 | Effect.mapError(toPullRequestError("preview")), | ||
| 1710 | Effect.map((value) => | ||
| 1711 | previewFields({ | ||
| 1712 | ...value, | ||
| 1713 | projectId: project.project.id, | ||
| 1714 | repository: project.repository, | ||
| 1715 | }), | ||
| 1716 | ), | ||
| 1717 | ), | ||
| 1718 | ), | ||
| 1719 | ); | ||
| 1720 | |||
| 1682 | const activityUncached: PullRequestService["Service"]["activity"] = (input) => | 1721 | const activityUncached: PullRequestService["Service"]["activity"] = (input) => |
| 1683 | requireProject(input).pipe( | 1722 | requireProject(input).pipe( |
| 1684 | Effect.flatMap((project) => | 1723 | Effect.flatMap((project) => |
| ... | @@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () { | ... | @@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () { |
| 2885 | timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), | 2924 | timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), |
| 2886 | }, | 2925 | }, |
| 2887 | ); | 2926 | ); |
| 2927 | |||
| 2928 | const previewCache = yield* Cache.makeWith( | ||
| 2929 | (key: string) => { | ||
| 2930 | return previewUncached(refOfCacheKey(key)); | ||
| 2931 | }, | ||
| 2932 | { | ||
| 2933 | capacity: DETAIL_CACHE_CAPACITY, | ||
| 2934 | timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), | ||
| 2935 | }, | ||
| 2936 | ); | ||
| 2937 | const preview: PullRequestService["Service"]["preview"] = (input) => { | ||
| 2938 | const key = refCacheKey(input); | ||
| 2939 | return Cache.getSuccess(detailCache, key).pipe( | ||
| 2940 | Effect.flatMap( | ||
| 2941 | Option.match({ | ||
| 2942 | onNone: () => Cache.get(previewCache, key), | ||
| 2943 | onSome: (detail) => Effect.succeed(previewFields(detail)), | ||
| 2944 | }), | ||
| 2945 | ), | ||
| 2946 | ); | ||
| 2947 | }; | ||
| 2888 | const activity: PullRequestService["Service"]["activity"] = (input) => { | 2948 | const activity: PullRequestService["Service"]["activity"] = (input) => { |
| 2889 | const key = refCacheKey(input); | 2949 | const key = refCacheKey(input); |
| 2890 | return Cache.get(activityCache, key); | 2950 | return Cache.get(activityCache, key); |
| ... | @@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () { | ... | @@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () { |
| 3131 | refreshAfterTurn, | 3191 | refreshAfterTurn, |
| 3132 | detail: credentialCached(detail), | 3192 | detail: credentialCached(detail), |
| 3133 | activity: credentialCached(activity), | 3193 | activity: credentialCached(activity), |
| 3194 | preview: credentialCached(preview), | ||
| 3134 | threadComments, | 3195 | threadComments, |
| 3135 | diff: credentialCached(diff), | 3196 | diff: credentialCached(diff), |
| 3136 | diffFileContents, | 3197 | diffFileContents, |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+43-15| ... | @@ -17,7 +17,6 @@ import { | ... | @@ -17,7 +17,6 @@ import { |
| 17 | decodePullRequestSearchJson, | 17 | decodePullRequestSearchJson, |
| 18 | decodePullRequestStacksJson, | 18 | decodePullRequestStacksJson, |
| 19 | decodeLabelCandidatesJson, | 19 | decodeLabelCandidatesJson, |
| 20 | decodeRepositoryAccessJson, | ||
| 21 | decodeReviewerCandidatesJson, | 20 | decodeReviewerCandidatesJson, |
| 22 | decodeReviewThreadCommentsJson, | 21 | decodeReviewThreadCommentsJson, |
| 23 | decodeReviewThreadsJson, | 22 | decodeReviewThreadsJson, |
| ... | @@ -810,31 +809,38 @@ describe("reaction decoding", () => { | ... | @@ -810,31 +809,38 @@ describe("reaction decoding", () => { |
| 810 | describe("repository access decoding", () => { | 809 | describe("repository access decoding", () => { |
| 811 | const repositoryJson = (viewerPermission?: string | null) => | 810 | const repositoryJson = (viewerPermission?: string | null) => |
| 812 | JSON.stringify({ | 811 | JSON.stringify({ |
| 813 | mergeCommitAllowed: true, | 812 | data: { |
| 814 | squashMergeAllowed: false, | 813 | repository: { |
| 815 | rebaseMergeAllowed: true, | 814 | pullRequest: null, |
| 816 | ...(viewerPermission === undefined ? {} : { viewerPermission }), | 815 | mergeCommitAllowed: true, |
| 816 | squashMergeAllowed: false, | ||
| 817 | rebaseMergeAllowed: true, | ||
| 818 | ...(viewerPermission === undefined ? {} : { viewerPermission }), | ||
| 819 | }, | ||
| 820 | }, | ||
| 817 | }); | 821 | }); |
| 818 | 822 | ||
| 819 | it("reads the three settings gh reports", () => { | 823 | it("reads merge settings with viewer permissions", () => { |
| 820 | expect( | 824 | expect( |
| 821 | expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities, | 825 | expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, |
| 822 | ).toEqual({ merge: true, squash: false, rebase: true }); | 826 | ).toEqual({ merge: true, squash: false, rebase: true }); |
| 823 | }); | 827 | }); |
| 824 | 828 | ||
| 825 | it("fails rather than defaulting open when a setting is missing", () => { | 829 | it("fails rather than defaulting open when a setting is missing", () => { |
| 826 | const decoded = decodeRepositoryAccessJson(JSON.stringify({ mergeCommitAllowed: true })); | 830 | const decoded = decodeViewerPermissionsJson( |
| 831 | JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), | ||
| 832 | ); | ||
| 827 | expect(Result.isSuccess(decoded)).toBe(false); | 833 | expect(Result.isSuccess(decoded)).toBe(false); |
| 828 | }); | 834 | }); |
| 829 | 835 | ||
| 830 | it("counts the roles that can push as write, and the ones that cannot as read", () => { | 836 | it("counts the roles that can push as write, and the ones that cannot as read", () => { |
| 831 | for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { | 837 | for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { |
| 832 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( | 838 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( |
| 833 | true, | 839 | true, |
| 834 | ); | 840 | ); |
| 835 | } | 841 | } |
| 836 | for (const permission of ["TRIAGE", "READ", "NONE"]) { | 842 | for (const permission of ["TRIAGE", "READ", "NONE"]) { |
| 837 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( | 843 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( |
| 838 | false, | 844 | false, |
| 839 | ); | 845 | ); |
| 840 | } | 846 | } |
| ... | @@ -843,14 +849,23 @@ describe("repository access decoding", () => { | ... | @@ -843,14 +849,23 @@ describe("repository access decoding", () => { |
| 843 | it("withholds write where gh names no permission, which is not a standing it gave", () => { | 849 | it("withholds write where gh names no permission, which is not a standing it gave", () => { |
| 844 | // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes | 850 | // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes |
| 845 | // the press, where a missing one still leaves the pull request open on its host. | 851 | // the press, where a missing one still leaves the pull request open on its host. |
| 846 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false); | 852 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); |
| 847 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false); | 853 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); |
| 848 | }); | 854 | }); |
| 849 | }); | 855 | }); |
| 850 | 856 | ||
| 851 | describe("viewer permission decoding", () => { | 857 | describe("viewer permission decoding", () => { |
| 852 | const viewerJson = (repository: Record<string, unknown>) => | 858 | const viewerJson = (repository: Record<string, unknown>) => |
| 853 | JSON.stringify({ data: { repository } }); | 859 | JSON.stringify({ |
| 860 | data: { | ||
| 861 | repository: { | ||
| 862 | mergeCommitAllowed: true, | ||
| 863 | squashMergeAllowed: false, | ||
| 864 | rebaseMergeAllowed: true, | ||
| 865 | ...repository, | ||
| 866 | }, | ||
| 867 | }, | ||
| 868 | }); | ||
| 854 | 869 | ||
| 855 | it("reads the repository's role and the pull request's own viewer fields together", () => { | 870 | it("reads the repository's role and the pull request's own viewer fields together", () => { |
| 856 | expect( | 871 | expect( |
| ... | @@ -862,7 +877,13 @@ describe("viewer permission decoding", () => { | ... | @@ -862,7 +877,13 @@ describe("viewer permission decoding", () => { |
| 862 | }), | 877 | }), |
| 863 | ), | 878 | ), |
| 864 | ), | 879 | ), |
| 865 | ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true }); | 880 | ).toEqual({ |
| 881 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | ||
| 882 | canWrite: false, | ||
| 883 | canTriage: false, | ||
| 884 | canUpdate: true, | ||
| 885 | didAuthor: true, | ||
| 886 | }); | ||
| 866 | }); | 887 | }); |
| 867 | 888 | ||
| 868 | it("says no to a passer-by on a repository they can only read", () => { | 889 | it("says no to a passer-by on a repository they can only read", () => { |
| ... | @@ -875,7 +896,13 @@ describe("viewer permission decoding", () => { | ... | @@ -875,7 +896,13 @@ describe("viewer permission decoding", () => { |
| 875 | }), | 896 | }), |
| 876 | ), | 897 | ), |
| 877 | ), | 898 | ), |
| 878 | ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false }); | 899 | ).toEqual({ |
| 900 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | ||
| 901 | canWrite: false, | ||
| 902 | canTriage: false, | ||
| 903 | canUpdate: false, | ||
| 904 | didAuthor: false, | ||
| 905 | }); | ||
| 879 | }); | 906 | }); |
| 880 | 907 | ||
| 881 | it("reads silence as permission, but not as authorship", () => { | 908 | it("reads silence as permission, but not as authorship", () => { |
| ... | @@ -883,6 +910,7 @@ describe("viewer permission decoding", () => { | ... | @@ -883,6 +910,7 @@ describe("viewer permission decoding", () => { |
| 883 | // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, | 910 | // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, |
| 884 | // and claiming it for someone who did not is how an author's own rules get handed out. | 911 | // and claiming it for someone who did not is how an author's own rules get handed out. |
| 885 | expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ | 912 | expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ |
| 913 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | ||
| 886 | canWrite: false, | 914 | canWrite: false, |
| 887 | canTriage: false, | 915 | canTriage: false, |
| 888 | canUpdate: true, | 916 | canUpdate: true, |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+187-35| ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | import type { | 5 | import type { |
| 6 | PullRequestStackMembership, | 6 | PullRequestStackMembership, |
| 7 | PullRequestActor, | 7 | PullRequestActor, |
| 8 | PullRequestPreview, | ||
| 8 | PullRequestCheck, | 9 | PullRequestCheck, |
| 9 | PullRequestCheckStatus, | 10 | PullRequestCheckStatus, |
| 10 | PullRequestChecksState, | 11 | PullRequestChecksState, |
| ... | @@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({ | ... | @@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({ |
| 596 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), | 597 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), |
| 597 | }); | 598 | }); |
| 598 | 599 | ||
| 600 | const RawCoreSchema = Schema.Struct({ | ||
| 601 | data: Schema.Struct({ | ||
| 602 | repository: Schema.Struct({ | ||
| 603 | ...RawRepositoryAccessSchema.fields, | ||
| 604 | pullRequest: Schema.Struct({ | ||
| 605 | ...RawDetailSchema.fields, | ||
| 606 | ...RawViewerFieldsSchema.fields, | ||
| 607 | viewerCanUpdateBranch: Schema.Boolean, | ||
| 608 | baseRef: Schema.NullOr( | ||
| 609 | Schema.Struct({ | ||
| 610 | compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), | ||
| 611 | }), | ||
| 612 | ), | ||
| 613 | reviewRequests: Schema.Struct({ | ||
| 614 | nodes: Schema.Array( | ||
| 615 | Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), | ||
| 616 | ), | ||
| 617 | }), | ||
| 618 | labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), | ||
| 619 | commits: Schema.Struct({ | ||
| 620 | nodes: Schema.Array( | ||
| 621 | Schema.Struct({ | ||
| 622 | commit: Schema.Struct({ | ||
| 623 | statusCheckRollup: Schema.NullOr( | ||
| 624 | Schema.Struct({ | ||
| 625 | contexts: Schema.Struct({ | ||
| 626 | nodes: Schema.Array( | ||
| 627 | Schema.Struct({ | ||
| 628 | ...RawCheckSchema.fields, | ||
| 629 | checkSuite: Schema.optional( | ||
| 630 | Schema.NullOr( | ||
| 631 | Schema.Struct({ | ||
| 632 | workflowRun: Schema.NullOr( | ||
| 633 | Schema.Struct({ | ||
| 634 | workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), | ||
| 635 | }), | ||
| 636 | ), | ||
| 637 | }), | ||
| 638 | ), | ||
| 639 | ), | ||
| 640 | }), | ||
| 641 | ), | ||
| 642 | pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), | ||
| 643 | }), | ||
| 644 | }), | ||
| 645 | ), | ||
| 646 | }), | ||
| 647 | }), | ||
| 648 | ), | ||
| 649 | }), | ||
| 650 | }), | ||
| 651 | }), | ||
| 652 | }), | ||
| 653 | }); | ||
| 654 | const decodeCore = decodeJsonResult(RawCoreSchema); | ||
| 655 | |||
| 599 | const RawPullRequestFileSchema = Schema.Struct({ | 656 | const RawPullRequestFileSchema = Schema.Struct({ |
| 600 | filename: Schema.String, | 657 | filename: Schema.String, |
| 601 | status: Schema.optional(Schema.NullOr(Schema.String)), | 658 | status: Schema.optional(Schema.NullOr(Schema.String)), |
| ... | @@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = | ... | @@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = |
| 644 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; | 701 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; |
| 645 | 702 | ||
| 646 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; | 703 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; |
| 704 | |||
| 705 | /** Pull refs let the comparison share the detail read without first resolving a fork branch. */ | ||
| 706 | export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | ||
| 707 | repository(owner: $owner, name: $name) { | ||
| 708 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | ||
| 709 | pullRequest(number: $number) { | ||
| 710 | number title url body state isDraft mergeable reviewDecision | ||
| 711 | additions deletions changedFiles createdAt updatedAt mergedAt closedAt | ||
| 712 | headRefName baseRefName headRefOid isCrossRepository | ||
| 713 | headRepositoryOwner { login } | ||
| 714 | author { login avatarUrl ... on User { id name } } | ||
| 715 | autoMergeRequest { mergeMethod } | ||
| 716 | viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch | ||
| 717 | baseRef { compare(headRef: $headRef) { behindBy } } | ||
| 718 | reviewRequests(first: 100) { | ||
| 719 | nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } | ||
| 720 | } | ||
| 721 | labels(first: 100) { nodes { name color } } | ||
| 722 | commits(last: 1) { | ||
| 723 | nodes { commit { statusCheckRollup { contexts(first: 100) { | ||
| 724 | nodes { | ||
| 725 | __typename | ||
| 726 | ... on StatusContext { context state targetUrl createdAt description } | ||
| 727 | ... on CheckRun { | ||
| 728 | name status conclusion startedAt completedAt detailsUrl | ||
| 729 | checkSuite { workflowRun { workflow { name } } } | ||
| 730 | } | ||
| 731 | } | ||
| 732 | pageInfo { hasNextPage } | ||
| 733 | } } } } | ||
| 734 | } | ||
| 735 | } | ||
| 736 | } | ||
| 737 | }`; | ||
| 738 | |||
| 739 | export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | ||
| 740 | repository(owner: $owner, name: $name) { | ||
| 741 | pullRequest(number: $number) { | ||
| 742 | number title url state isDraft createdAt | ||
| 743 | author { login avatarUrl ... on User { name } } | ||
| 744 | } | ||
| 745 | } | ||
| 746 | }`; | ||
| 747 | |||
| 748 | const decodePullRequestPreview = decodeJsonResult( | ||
| 749 | Schema.Struct({ | ||
| 750 | data: Schema.Struct({ | ||
| 751 | repository: Schema.Struct({ | ||
| 752 | pullRequest: Schema.Struct({ | ||
| 753 | number: Schema.Int, | ||
| 754 | title: Schema.String, | ||
| 755 | url: Schema.String, | ||
| 756 | state: Schema.String, | ||
| 757 | isDraft: Schema.Boolean, | ||
| 758 | createdAt: Schema.String, | ||
| 759 | author: Schema.NullOr(RawActorSchema), | ||
| 760 | }), | ||
| 761 | }), | ||
| 762 | }), | ||
| 763 | }), | ||
| 764 | ); | ||
| 765 | |||
| 766 | export function decodePullRequestPreviewJson( | ||
| 767 | raw: string, | ||
| 768 | ): Result.Result<Omit<PullRequestPreview, "projectId" | "repository">, DecodeFailure> { | ||
| 769 | return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ | ||
| 770 | ...data.repository.pullRequest, | ||
| 771 | author: toActor(data.repository.pullRequest.author), | ||
| 772 | state: toState(data.repository.pullRequest), | ||
| 773 | })); | ||
| 774 | } | ||
| 775 | |||
| 647 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; | 776 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; |
| 648 | 777 | ||
| 649 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ | 778 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ |
| ... | @@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: { | ... | @@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: { |
| 1034 | }); | 1163 | }); |
| 1035 | } | 1164 | } |
| 1036 | 1165 | ||
| 1037 | /** | ||
| 1038 | * `viewerPermission` rides along with the merge settings rather than being asked for on its own: | ||
| 1039 | * `gh repo view --json` serves both out of the same GraphQL repository object, so the viewer's | ||
| 1040 | * standing on the repository costs no request of its own. | ||
| 1041 | */ | ||
| 1042 | export const REPOSITORY_ACCESS_JSON_FIELDS = | ||
| 1043 | "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; | ||
| 1044 | |||
| 1045 | export interface GitHubPullRequestListItem { | 1166 | export interface GitHubPullRequestListItem { |
| 1046 | readonly stack?: PullRequestStackMembership; | 1167 | readonly stack?: PullRequestStackMembership; |
| 1047 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ | 1168 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ |
| ... | @@ -1489,7 +1610,6 @@ const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunA | ... | @@ -1489,7 +1610,6 @@ const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunA |
| 1489 | const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); | 1610 | const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); |
| 1490 | const decodeActivity = decodeJsonResult(RawActivitySchema); | 1611 | const decodeActivity = decodeJsonResult(RawActivitySchema); |
| 1491 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); | 1612 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); |
| 1492 | const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); | ||
| 1493 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); | 1613 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); |
| 1494 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); | 1614 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); |
| 1495 | 1615 | ||
| ... | @@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson( | ... | @@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson( |
| 1689 | return Result.succeed(stats); | 1809 | return Result.succeed(stats); |
| 1690 | } | 1810 | } |
| 1691 | 1811 | ||
| 1812 | export interface GitHubPullRequestCore extends GitHubPullRequestDetail { | ||
| 1813 | readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; | ||
| 1814 | readonly comparison: GitHubBaseComparison | null; | ||
| 1815 | readonly checksTruncated: boolean; | ||
| 1816 | } | ||
| 1817 | |||
| 1818 | export function decodePullRequestCoreJson( | ||
| 1819 | raw: string, | ||
| 1820 | ): Result.Result<GitHubPullRequestCore, DecodeFailure> { | ||
| 1821 | const decoded = decodeCore(raw); | ||
| 1822 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 1823 | const repository = decoded.success.data.repository; | ||
| 1824 | const pr = repository.pullRequest; | ||
| 1825 | const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; | ||
| 1826 | return Result.succeed({ | ||
| 1827 | ...toDetail({ | ||
| 1828 | ...pr, | ||
| 1829 | reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => | ||
| 1830 | requestedReviewer === null ? [] : [requestedReviewer], | ||
| 1831 | ), | ||
| 1832 | labels: pr.labels.nodes, | ||
| 1833 | statusCheckRollup: | ||
| 1834 | contexts?.nodes.map((check) => ({ | ||
| 1835 | ...check, | ||
| 1836 | workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, | ||
| 1837 | })) ?? [], | ||
| 1838 | }), | ||
| 1839 | viewerAccess: { | ||
| 1840 | canWrite: toCanWrite(repository.viewerPermission), | ||
| 1841 | canTriage: toCanTriage(repository.viewerPermission), | ||
| 1842 | ...toPullRequestViewerFields(pr), | ||
| 1843 | mergeCapabilities: { | ||
| 1844 | merge: repository.mergeCommitAllowed, | ||
| 1845 | squash: repository.squashMergeAllowed, | ||
| 1846 | rebase: repository.rebaseMergeAllowed, | ||
| 1847 | }, | ||
| 1848 | }, | ||
| 1849 | comparison: | ||
| 1850 | pr.state !== "OPEN" || pr.baseRef?.compare == null | ||
| 1851 | ? null | ||
| 1852 | : { | ||
| 1853 | behindBy: pr.baseRef.compare.behindBy, | ||
| 1854 | viewerCanUpdate: pr.viewerCanUpdateBranch, | ||
| 1855 | }, | ||
| 1856 | checksTruncated: contexts?.pageInfo.hasNextPage === true, | ||
| 1857 | }); | ||
| 1858 | } | ||
| 1859 | |||
| 1692 | export function decodePullRequestDetailJson( | 1860 | export function decodePullRequestDetailJson( |
| 1693 | raw: string, | 1861 | raw: string, |
| 1694 | ): Result.Result<GitHubPullRequestDetail, DecodeFailure> { | 1862 | ): Result.Result<GitHubPullRequestDetail, DecodeFailure> { |
| ... | @@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result< | ... | @@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result< |
| 2052 | }); | 2220 | }); |
| 2053 | } | 2221 | } |
| 2054 | 2222 | ||
| 2055 | /** What one `gh repo view` answers: what the repository allows, and where the viewer stands. */ | 2223 | /** Repository settings returned alongside the pull request viewer permissions. */ |
| 2056 | export interface GitHubRepositoryAccess { | 2224 | export interface GitHubRepositoryAccess { |
| 2057 | readonly mergeCapabilities: PullRequestMergeCapabilities; | 2225 | readonly mergeCapabilities: PullRequestMergeCapabilities; |
| 2058 | readonly canWrite: boolean; | 2226 | readonly canWrite: boolean; |
| ... | @@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { | ... | @@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { |
| 2084 | return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); | 2252 | return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); |
| 2085 | } | 2253 | } |
| 2086 | 2254 | ||
| 2087 | export function decodeRepositoryAccessJson( | ||
| 2088 | raw: string, | ||
| 2089 | ): Result.Result<GitHubRepositoryAccess, DecodeFailure> { | ||
| 2090 | const decoded = decodeRepositoryAccess(raw); | ||
| 2091 | return Result.isSuccess(decoded) | ||
| 2092 | ? Result.succeed({ | ||
| 2093 | mergeCapabilities: { | ||
| 2094 | merge: decoded.success.mergeCommitAllowed, | ||
| 2095 | squash: decoded.success.squashMergeAllowed, | ||
| 2096 | rebase: decoded.success.rebaseMergeAllowed, | ||
| 2097 | }, | ||
| 2098 | canWrite: toCanWrite(decoded.success.viewerPermission), | ||
| 2099 | }) | ||
| 2100 | : Result.fail(decoded.failure); | ||
| 2101 | } | ||
| 2102 | |||
| 2103 | /** | 2255 | /** |
| 2104 | * Who a review may be asked of, and who it has already been asked of, in one read. | 2256 | * Who a review may be asked of, and who it has already been asked of, in one read. |
| 2105 | * | 2257 | * |
| ... | @@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess { | ... | @@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess { |
| 2428 | readonly canUpdateBranch?: boolean; | 2580 | readonly canUpdateBranch?: boolean; |
| 2429 | } | 2581 | } |
| 2430 | 2582 | ||
| 2431 | /** | 2583 | /** Core detail and write checks share one read of permissions and merge settings. */ |
| 2432 | * The viewer's standing, asked on its own. Only the write path needs this: reading a pull request | ||
| 2433 | * already carries the same three fields on calls it was making anyway, and this exists so that a | ||
| 2434 | * merge or a close is decided by what GitHub says now rather than by what the page was told when | ||
| 2435 | * it loaded. | ||
| 2436 | */ | ||
| 2437 | export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | 2584 | export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { |
| 2438 | repository(owner: $owner, name: $name) { | 2585 | repository(owner: $owner, name: $name) { |
| 2439 | viewerPermission | 2586 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission |
| 2440 | pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } | 2587 | pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } |
| 2441 | } | 2588 | } |
| 2442 | }`; | 2589 | }`; |
| ... | @@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S | ... | @@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S |
| 2444 | const RawViewerPermissionsSchema = Schema.Struct({ | 2591 | const RawViewerPermissionsSchema = Schema.Struct({ |
| 2445 | data: Schema.Struct({ | 2592 | data: Schema.Struct({ |
| 2446 | repository: Schema.Struct({ | 2593 | repository: Schema.Struct({ |
| 2447 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), | 2594 | ...RawRepositoryAccessSchema.fields, |
| 2448 | /** Null for a number that names no pull request the viewer can see. */ | 2595 | /** Null for a number that names no pull request the viewer can see. */ |
| 2449 | pullRequest: Schema.NullOr(RawViewerFieldsSchema), | 2596 | pullRequest: Schema.NullOr(RawViewerFieldsSchema), |
| 2450 | }), | 2597 | }), |
| ... | @@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); | ... | @@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); |
| 2455 | 2602 | ||
| 2456 | export function decodeViewerPermissionsJson( | 2603 | export function decodeViewerPermissionsJson( |
| 2457 | raw: string, | 2604 | raw: string, |
| 2458 | ): Result.Result<GitHubViewerAccess, DecodeFailure> { | 2605 | ): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> { |
| 2459 | const decoded = decodeViewerPermissions(raw); | 2606 | const decoded = decodeViewerPermissions(raw); |
| 2460 | if (!Result.isSuccess(decoded)) { | 2607 | if (!Result.isSuccess(decoded)) { |
| 2461 | return Result.fail(decoded.failure); | 2608 | return Result.fail(decoded.failure); |
| 2462 | } | 2609 | } |
| 2463 | const repository = decoded.success.data.repository; | 2610 | const repository = decoded.success.data.repository; |
| 2464 | return Result.succeed({ | 2611 | return Result.succeed({ |
| 2612 | mergeCapabilities: { | ||
| 2613 | merge: repository.mergeCommitAllowed, | ||
| 2614 | squash: repository.squashMergeAllowed, | ||
| 2615 | rebase: repository.rebaseMergeAllowed, | ||
| 2616 | }, | ||
| 2465 | canWrite: toCanWrite(repository.viewerPermission), | 2617 | canWrite: toCanWrite(repository.viewerPermission), |
| 2466 | canTriage: toCanTriage(repository.viewerPermission), | 2618 | canTriage: toCanTriage(repository.viewerPermission), |
| 2467 | ...toPullRequestViewerFields(repository.pullRequest), | 2619 | ...toPullRequestViewerFields(repository.pullRequest), |
apps/server/src/ws.ts+6| ... | @@ -2729,6 +2729,12 @@ const makeWsRpcLayer = ( | ... | @@ -2729,6 +2729,12 @@ const makeWsRpcLayer = ( |
| 2729 | "rpc.aggregate": "pull-requests", | 2729 | "rpc.aggregate": "pull-requests", |
| 2730 | }, | 2730 | }, |
| 2731 | ), | 2731 | ), |
| 2732 | [WS_METHODS.pullRequestsPreview]: (input) => | ||
| 2733 | observeRpcEffect( | ||
| 2734 | WS_METHODS.pullRequestsPreview, | ||
| 2735 | withPullRequestViewer(input, pullRequests.preview(input)), | ||
| 2736 | { "rpc.aggregate": "pull-requests" }, | ||
| 2737 | ), | ||
| 2732 | [WS_METHODS.pullRequestsActivity]: (input) => | 2738 | [WS_METHODS.pullRequestsActivity]: (input) => |
| 2733 | observeRpcEffect( | 2739 | observeRpcEffect( |
| 2734 | WS_METHODS.pullRequestsActivity, | 2740 | WS_METHODS.pullRequestsActivity, |
apps/web/src/components/ChatMarkdown.tsx+1| ... | @@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2905 | environmentId, | 2905 | environmentId, |
| 2906 | input: { | 2906 | input: { |
| 2907 | projectId: pullRequestProject.id, | 2907 | projectId: pullRequestProject.id, |
| 2908 | host: pullRequestCandidate.authority ?? pullRequestCandidate.host, | ||
| 2908 | repository: | 2909 | repository: |
| 2909 | sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? | 2910 | sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? |
| 2910 | pullRequestCandidate.repository, | 2911 | pullRequestCandidate.repository, |
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+32-31| ... | @@ -42,11 +42,10 @@ export function PullRequestLinkPreview({ | ... | @@ -42,11 +42,10 @@ export function PullRequestLinkPreview({ |
| 42 | }) | 42 | }) |
| 43 | : null, | 43 | : null, |
| 44 | ); | 44 | ); |
| 45 | const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, { | 45 | const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { |
| 46 | reportFailure: false, | 46 | reportFailure: false, |
| 47 | reportDefect: false, | 47 | reportDefect: false, |
| 48 | }); | 48 | }); |
| 49 | |||
| 50 | const trigger = confirmBeforeOpen | 49 | const trigger = confirmBeforeOpen |
| 51 | ? cloneElement(link, { | 50 | ? cloneElement(link, { |
| 52 | onClick: (event) => { | 51 | onClick: (event) => { |
| ... | @@ -56,7 +55,7 @@ export function PullRequestLinkPreview({ | ... | @@ -56,7 +55,7 @@ export function PullRequestLinkPreview({ |
| 56 | if (resolvingClick) return; | 55 | if (resolvingClick) return; |
| 57 | setOpen(false); | 56 | setOpen(false); |
| 58 | setResolvingClick(true); | 57 | setResolvingClick(true); |
| 59 | void readDetail({ environmentId: target.environmentId, input: target.input }) | 58 | void readPreview(target) |
| 60 | .then(async (result) => { | 59 | .then(async (result) => { |
| 61 | if (isAtomCommandInterrupted(result)) return; | 60 | if (isAtomCommandInterrupted(result)) return; |
| 62 | if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; | 61 | if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; |
| ... | @@ -84,36 +83,38 @@ export function PullRequestLinkPreview({ | ... | @@ -84,36 +83,38 @@ export function PullRequestLinkPreview({ |
| 84 | return ( | 83 | return ( |
| 85 | <PreviewCard open={open} onOpenChange={setOpen}> | 84 | <PreviewCard open={open} onOpenChange={setOpen}> |
| 86 | <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} /> | 85 | <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} /> |
| 87 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> | 86 | {detail !== null || detailQuery.error !== null ? ( |
| 88 | {detail === null ? ( | 87 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> |
| 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> | 88 | {detail === null ? ( |
| 90 | {detailQuery.isPending ? "Loading pull request details…" : originalUrl} | 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> |
| 91 | </p> | 90 | {originalUrl} |
| 92 | ) : ( | ||
| 93 | <div className="min-w-0"> | ||
| 94 | <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"> | ||
| 95 | <span className="min-w-0 truncate">{detail.repository}</span> | ||
| 96 | <span className="shrink-0">#{detail.number}</span> | ||
| 97 | <span aria-hidden>·</span> | ||
| 98 | {state === null ? null : ( | ||
| 99 | <span className="inline-flex shrink-0 items-center gap-1"> | ||
| 100 | <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} /> | ||
| 101 | {state.label} | ||
| 102 | </span> | ||
| 103 | )} | ||
| 104 | </div> | ||
| 105 | <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty"> | ||
| 106 | {detail.title} | ||
| 107 | </p> | 91 | </p> |
| 108 | <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> | 92 | ) : ( |
| 109 | <PullRequestActorAvatar actor={detail.author} className="size-4" /> | 93 | <div className="min-w-0"> |
| 110 | <span className="min-w-0 truncate">{authorLabel}</span> | 94 | <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"> |
| 111 | <span aria-hidden>·</span> | 95 | <span className="min-w-0 truncate">{detail.repository}</span> |
| 112 | <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span> | 96 | <span className="shrink-0">#{detail.number}</span> |
| 97 | <span aria-hidden>·</span> | ||
| 98 | {state === null ? null : ( | ||
| 99 | <span className="inline-flex shrink-0 items-center gap-1"> | ||
| 100 | <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} /> | ||
| 101 | {state.label} | ||
| 102 | </span> | ||
| 103 | )} | ||
| 104 | </div> | ||
| 105 | <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty"> | ||
| 106 | {detail.title} | ||
| 107 | </p> | ||
| 108 | <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 109 | <PullRequestActorAvatar actor={detail.author} className="size-4" /> | ||
| 110 | <span className="min-w-0 truncate">{authorLabel}</span> | ||
| 111 | <span aria-hidden>·</span> | ||
| 112 | <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span> | ||
| 113 | </div> | ||
| 113 | </div> | 114 | </div> |
| 114 | </div> | 115 | )} |
| 115 | )} | 116 | </PreviewCardPopup> |
| 116 | </PreviewCardPopup> | 117 | ) : null} |
| 117 | </PreviewCard> | 118 | </PreviewCard> |
| 118 | ); | 119 | ); |
| 119 | } | 120 | } |
knip.jsonc+1| ... | @@ -23,6 +23,7 @@ | ... | @@ -23,6 +23,7 @@ |
| 23 | "src/claude-history-worker.ts!", | 23 | "src/claude-history-worker.ts!", |
| 24 | "scripts/cli.ts", | 24 | "scripts/cli.ts", |
| 25 | "scripts/evaluate-thread-titles.ts", | 25 | "scripts/evaluate-thread-titles.ts", |
| 26 | "scripts/measure-pr-preview.ts", | ||
| 26 | "src/provider/testFixtures/*.mjs", | 27 | "src/provider/testFixtures/*.mjs", |
| 27 | ], | 28 | ], |
| 28 | // Keep the transitive Effect runtime pinned for standalone npm installs. | 29 | // Keep the transitive Effect runtime pinned for standalone npm installs. |
packages/client-runtime/src/state/pullRequestRouting.ts+1| ... | @@ -29,6 +29,7 @@ const reads = new Set<string>([ | ... | @@ -29,6 +29,7 @@ const reads = new Set<string>([ |
| 29 | WS_METHODS.pullRequestsSummary, | 29 | WS_METHODS.pullRequestsSummary, |
| 30 | WS_METHODS.pullRequestsStack, | 30 | WS_METHODS.pullRequestsStack, |
| 31 | WS_METHODS.pullRequestsDetail, | 31 | WS_METHODS.pullRequestsDetail, |
| 32 | WS_METHODS.pullRequestsPreview, | ||
| 32 | WS_METHODS.pullRequestsActivity, | 33 | WS_METHODS.pullRequestsActivity, |
| 33 | WS_METHODS.pullRequestsThreadComments, | 34 | WS_METHODS.pullRequestsThreadComments, |
| 34 | WS_METHODS.pullRequestsDiffFileContents, | 35 | WS_METHODS.pullRequestsDiffFileContents, |
packages/client-runtime/src/state/pullRequests.test.ts+78| ... | @@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () => | ... | @@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () => |
| 887 | ), | 887 | ), |
| 888 | ); | 888 | ); |
| 889 | 889 | ||
| 890 | it.effect("keeps hover previews fresh after edits and turns", () => | ||
| 891 | Effect.scoped( | ||
| 892 | Effect.gen(function* () { | ||
| 893 | const refreshEvents = yield* PubSub.unbounded<number>(); | ||
| 894 | let title = "Original title"; | ||
| 895 | let reads = 0; | ||
| 896 | const read = (input: unknown) => | ||
| 897 | Effect.sync(() => { | ||
| 898 | expect(input).toEqual(reference); | ||
| 899 | reads++; | ||
| 900 | return { ...reference, title }; | ||
| 901 | }); | ||
| 902 | const reference = { | ||
| 903 | projectId: ProjectId.make("project-1"), | ||
| 904 | repository: "acme/web", | ||
| 905 | number: 1, | ||
| 906 | host: "github.example.com", | ||
| 907 | }; | ||
| 908 | const client = { | ||
| 909 | [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), | ||
| 910 | [WS_METHODS.pullRequestsPreview]: read, | ||
| 911 | [WS_METHODS.pullRequestsDetail]: read, | ||
| 912 | [WS_METHODS.pullRequestsUpdate]: (input: { title: string }) => | ||
| 913 | Effect.sync(() => { | ||
| 914 | title = input.title; | ||
| 915 | }), | ||
| 916 | [WS_METHODS.pullRequestsRunAction]: () => | ||
| 917 | Effect.sync(() => { | ||
| 918 | title = "Closed"; | ||
| 919 | }), | ||
| 920 | [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, | ||
| 921 | } as unknown as WsRpcProtocolClient; | ||
| 922 | const { atoms, registry } = yield* makeTestRuntime(client); | ||
| 923 | const target = { environmentId: TARGET.environmentId, input: reference }; | ||
| 924 | const preview = atoms.preview(target); | ||
| 925 | const unmount = registry.mount(preview); | ||
| 926 | yield* Effect.addFinalizer(() => Effect.sync(unmount)); | ||
| 927 | expect( | ||
| 928 | (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, | ||
| 929 | ).toBe(title); | ||
| 930 | yield* Effect.promise(() => executeAtomQuery(registry, preview)); | ||
| 931 | expect(reads).toBe(1); | ||
| 932 | const edited = yield* Effect.promise(() => | ||
| 933 | atoms.update.run(registry, { ...target, input: { ...reference, title: "Edited" } }), | ||
| 934 | ); | ||
| 935 | expect(AsyncResult.isSuccess(edited)).toBe(true); | ||
| 936 | expect( | ||
| 937 | (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, | ||
| 938 | ).toBe("Edited"); | ||
| 939 | yield* Effect.promise(() => | ||
| 940 | atoms.runAction.run(registry, { ...target, input: { ...reference, action: "close" } }), | ||
| 941 | ); | ||
| 942 | expect( | ||
| 943 | (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, | ||
| 944 | ).toBe("Closed"); | ||
| 945 | title = "Refreshed"; | ||
| 946 | yield* Effect.promise(() => | ||
| 947 | atoms.invalidate.run(registry, { | ||
| 948 | environmentId: target.environmentId, | ||
| 949 | input: { reference }, | ||
| 950 | }), | ||
| 951 | ); | ||
| 952 | expect( | ||
| 953 | (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, | ||
| 954 | ).toBe("Refreshed"); | ||
| 955 | const refreshed = Latch.makeUnsafe(); | ||
| 956 | const stop = registry.subscribe(preview, (result) => { | ||
| 957 | if (AsyncResult.isSuccess(result) && result.value.title === "After turn") | ||
| 958 | refreshed.openUnsafe(); | ||
| 959 | }); | ||
| 960 | yield* Effect.addFinalizer(() => Effect.sync(stop)); | ||
| 961 | title = "After turn"; | ||
| 962 | yield* PubSub.publish(refreshEvents, 1); | ||
| 963 | yield* refreshed.await; | ||
| 964 | }), | ||
| 965 | ), | ||
| 966 | ); | ||
| 967 | |||
| 890 | it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => | 968 | it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => |
| 891 | Effect.scoped( | 969 | Effect.scoped( |
| 892 | Effect.gen(function* () { | 970 | Effect.gen(function* () { |
packages/client-runtime/src/state/pullRequests.ts+18| ... | @@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 176 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | 176 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), |
| 177 | }), | 177 | }), |
| 178 | ); | 178 | ); |
| 179 | const preview = writableQueryFamily( | ||
| 180 | createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 181 | label: "environment-data:pull-requests:preview", | ||
| 182 | tag: WS_METHODS.pullRequestsPreview, | ||
| 183 | execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input), | ||
| 184 | staleTimeMs: 60_000, | ||
| 185 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | ||
| 186 | }), | ||
| 187 | ); | ||
| 179 | const labelCandidates = writableQueryFamily( | 188 | const labelCandidates = writableQueryFamily( |
| 180 | createEnvironmentRpcQueryAtomFamily(runtime, { | 189 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 181 | label: "environment-data:pull-requests:label-candidates", | 190 | label: "environment-data:pull-requests:label-candidates", |
| ... | @@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 221 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | 230 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), |
| 222 | }), | 231 | }), |
| 223 | detail, | 232 | detail, |
| 233 | preview, | ||
| 224 | activity, | 234 | activity, |
| 225 | threadComments: createEnvironmentRpcCommand(runtime, { | 235 | threadComments: createEnvironmentRpcCommand(runtime, { |
| 226 | label: "environment-data:pull-requests:thread-comments", | 236 | label: "environment-data:pull-requests:thread-comments", |
| ... | @@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 295 | label: "environment-data:pull-requests:run-action", | 305 | label: "environment-data:pull-requests:run-action", |
| 296 | tag: WS_METHODS.pullRequestsRunAction, | 306 | tag: WS_METHODS.pullRequestsRunAction, |
| 297 | execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input), | 307 | execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input), |
| 308 | onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), | ||
| 298 | scheduler: commandScheduler, | 309 | scheduler: commandScheduler, |
| 299 | concurrency: serialPerEnvironment, | 310 | concurrency: serialPerEnvironment, |
| 300 | }), | 311 | }), |
| ... | @@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 302 | label: "environment-data:pull-requests:update", | 313 | label: "environment-data:pull-requests:update", |
| 303 | tag: WS_METHODS.pullRequestsUpdate, | 314 | tag: WS_METHODS.pullRequestsUpdate, |
| 304 | execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), | 315 | execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), |
| 316 | onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), | ||
| 305 | scheduler: commandScheduler, | 317 | scheduler: commandScheduler, |
| 306 | concurrency: serialPerEnvironment, | 318 | concurrency: serialPerEnvironment, |
| 307 | }), | 319 | }), |
| ... | @@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 485 | label: "environment-data:pull-requests:invalidate", | 497 | label: "environment-data:pull-requests:invalidate", |
| 486 | tag: WS_METHODS.pullRequestsInvalidate, | 498 | tag: WS_METHODS.pullRequestsInvalidate, |
| 487 | execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input), | 499 | execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input), |
| 500 | onSuccess: ({ environmentId, input }, registry) => | ||
| 501 | Effect.sync(() => { | ||
| 502 | if (input.reference !== undefined) { | ||
| 503 | registry.refresh(preview({ environmentId, input: input.reference })); | ||
| 504 | } | ||
| 505 | }), | ||
| 488 | scheduler: commandScheduler, | 506 | scheduler: commandScheduler, |
| 489 | concurrency: serialPerEnvironment, | 507 | concurrency: serialPerEnvironment, |
| 490 | }), | 508 | }), |
packages/contracts/src/pullRequest.ts+14| ... | @@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ | ... | @@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ |
| 703 | }); | 703 | }); |
| 704 | export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type; | 704 | export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type; |
| 705 | 705 | ||
| 706 | /** The complete hover card, without checks, permissions, or branch comparisons. */ | ||
| 707 | export const PullRequestPreview = Schema.Struct({ | ||
| 708 | projectId: ProjectId, | ||
| 709 | repository: TrimmedNonEmptyString, | ||
| 710 | number: PositiveInt, | ||
| 711 | title: TrimmedNonEmptyString, | ||
| 712 | url: TrimmedNonEmptyString, | ||
| 713 | author: Schema.NullOr(PullRequestActor), | ||
| 714 | state: PullRequestState, | ||
| 715 | isDraft: Schema.Boolean, | ||
| 716 | createdAt: IsoDateTime, | ||
| 717 | }); | ||
| 718 | export type PullRequestPreview = typeof PullRequestPreview.Type; | ||
| 719 | |||
| 706 | /** | 720 | /** |
| 707 | * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar | 721 | * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar |
| 708 | * status check never loads permissions, repository settings, checks, or base comparison data. | 722 | * status check never loads permissions, repository settings, checks, or base comparison data. |
packages/contracts/src/rpc.ts+9| ... | @@ -111,6 +111,7 @@ import { | ... | @@ -111,6 +111,7 @@ import { |
| 111 | PullRequestCommentInput, | 111 | PullRequestCommentInput, |
| 112 | PullRequestCommentUpdateInput, | 112 | PullRequestCommentUpdateInput, |
| 113 | PullRequestDetail, | 113 | PullRequestDetail, |
| 114 | PullRequestPreview, | ||
| 114 | PullRequestDiffFileContentsInput, | 115 | PullRequestDiffFileContentsInput, |
| 115 | PullRequestDiffFileContentsResult, | 116 | PullRequestDiffFileContentsResult, |
| 116 | PullRequestFilesViewedResult, | 117 | PullRequestFilesViewedResult, |
| ... | @@ -395,6 +396,7 @@ export const WS_METHODS = { | ... | @@ -395,6 +396,7 @@ export const WS_METHODS = { |
| 395 | pullRequestsStack: "pullRequests.stack", | 396 | pullRequestsStack: "pullRequests.stack", |
| 396 | pullRequestsLinkedThreads: "pullRequests.linkedThreads", | 397 | pullRequestsLinkedThreads: "pullRequests.linkedThreads", |
| 397 | pullRequestsDetail: "pullRequests.detail", | 398 | pullRequestsDetail: "pullRequests.detail", |
| 399 | pullRequestsPreview: "pullRequests.preview", | ||
| 398 | pullRequestsActivity: "pullRequests.activity", | 400 | pullRequestsActivity: "pullRequests.activity", |
| 399 | pullRequestsThreadComments: "pullRequests.threadComments", | 401 | pullRequestsThreadComments: "pullRequests.threadComments", |
| 400 | pullRequestsDiffFileContents: "pullRequests.diffFileContents", | 402 | pullRequestsDiffFileContents: "pullRequests.diffFileContents", |
| ... | @@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { | ... | @@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { |
| 737 | error: PullRequestRpcError, | 739 | error: PullRequestRpcError, |
| 738 | }); | 740 | }); |
| 739 | 741 | ||
| 742 | const WsPullRequestsPreviewRpc = Rpc.make(WS_METHODS.pullRequestsPreview, { | ||
| 743 | payload: PullRequestRef, | ||
| 744 | success: PullRequestPreview, | ||
| 745 | error: PullRequestRpcError, | ||
| 746 | }); | ||
| 747 | |||
| 740 | const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { | 748 | const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { |
| 741 | payload: PullRequestRef, | 749 | payload: PullRequestRef, |
| 742 | success: PullRequestActivity, | 750 | success: PullRequestActivity, |
| ... | @@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1418 | WsPullRequestsStackRpc, | 1426 | WsPullRequestsStackRpc, |
| 1419 | WsPullRequestsLinkedThreadsRpc, | 1427 | WsPullRequestsLinkedThreadsRpc, |
| 1420 | WsPullRequestsDetailRpc, | 1428 | WsPullRequestsDetailRpc, |
| 1429 | WsPullRequestsPreviewRpc, | ||
| 1421 | WsPullRequestsActivityRpc, | 1430 | WsPullRequestsActivityRpc, |
| 1422 | WsPullRequestsThreadCommentsRpc, | 1431 | WsPullRequestsThreadCommentsRpc, |
| 1423 | WsPullRequestsDiffFileContentsRpc, | 1432 | WsPullRequestsDiffFileContentsRpc, |