| 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 | 73 | [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, |
| 74 | 74 | [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, |
| 75 | 75 | [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, |
| 76 | [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, | |
| 76 | 77 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, |
| 77 | 78 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
| 78 | 79 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+459-91| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Clock from "effect/Clock"; | |
| 3 | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | 5 | import * as Fiber from "effect/Fiber"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 16 | 17 | |
| 17 | 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 | 50 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); |
| 20 | 51 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => |
| 21 | 52 | Effect.succeed(output('{"data":{}}')), |
| ... | ... | @@ -33,7 +64,7 @@ const layer = it.layer( |
| 33 | 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 | 286 | ); |
| 256 | 287 | |
| 257 | 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 | 401 | it.effect("coalesces concurrent identity verification for the same host and credential", () => |
| 259 | 402 | Effect.gen(function* () { |
| 260 | 403 | mockedExecute.mockImplementation((input) => |
| ... | ... | @@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1741 | 1884 | mockedExecute.mockReturnValueOnce( |
| 1742 | 1885 | Effect.succeed( |
| 1743 | 1886 | output( |
| 1744 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 1745 | JSON.stringify({ | |
| 1746 | number: 7, | |
| 1747 | title: "Pull request 7", | |
| 1748 | url: "https://github.com/acme/web/pull/7", | |
| 1749 | headRefName: "feat/page", | |
| 1750 | headRefOid: "abc123", | |
| 1751 | isCrossRepository: false, | |
| 1752 | headRepositoryOwner: { login: "acme" }, | |
| 1753 | baseRefName: "main", | |
| 1754 | createdAt: "2026-07-01T00:00:00Z", | |
| 1755 | updatedAt: "2026-07-02T00:00:00Z", | |
| 1756 | }), | |
| 1887 | encodeJson( | |
| 1888 | coreResponse({ | |
| 1889 | number: 7, | |
| 1890 | title: "Pull request 7", | |
| 1891 | url: "https://github.com/acme/web/pull/7", | |
| 1892 | headRefName: "feat/page", | |
| 1893 | headRefOid: "abc123", | |
| 1894 | isCrossRepository: false, | |
| 1895 | headRepositoryOwner: { login: "acme" }, | |
| 1896 | baseRefName: "main", | |
| 1897 | createdAt: "2026-07-01T00:00:00Z", | |
| 1898 | updatedAt: "2026-07-02T00:00:00Z", | |
| 1899 | }), | |
| 1900 | ), | |
| 1757 | 1901 | ), |
| 1758 | 1902 | ), |
| 1759 | 1903 | ); |
| ... | ... | @@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1774 | 1918 | it.effect("finds and approves every workflow waiting on a maintainer", () => |
| 1775 | 1919 | Effect.gen(function* () { |
| 1776 | 1920 | const detail = output( |
| 1777 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 1778 | JSON.stringify({ | |
| 1779 | number: 7, | |
| 1780 | title: "Pull request 7", | |
| 1781 | url: "https://github.com/acme/web/pull/7", | |
| 1782 | headRefName: "feat/page", | |
| 1783 | headRefOid: "abc123", | |
| 1784 | isCrossRepository: true, | |
| 1785 | headRepositoryOwner: { login: "octocat" }, | |
| 1786 | baseRefName: "main", | |
| 1787 | createdAt: "2026-07-01T00:00:00Z", | |
| 1788 | updatedAt: "2026-07-02T00:00:00Z", | |
| 1789 | }), | |
| 1921 | encodeJson( | |
| 1922 | coreResponse({ | |
| 1923 | number: 7, | |
| 1924 | title: "Pull request 7", | |
| 1925 | url: "https://github.com/acme/web/pull/7", | |
| 1926 | headRefName: "feat/page", | |
| 1927 | headRefOid: "abc123", | |
| 1928 | isCrossRepository: true, | |
| 1929 | headRepositoryOwner: { login: "octocat" }, | |
| 1930 | baseRefName: "main", | |
| 1931 | createdAt: "2026-07-01T00:00:00Z", | |
| 1932 | updatedAt: "2026-07-02T00:00:00Z", | |
| 1933 | }), | |
| 1934 | ), | |
| 1790 | 1935 | ); |
| 1791 | 1936 | const heads = output( |
| 1792 | 1937 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. |
| ... | ... | @@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1902 | 2047 | updatedAt: "2026-07-02T00:00:00Z", |
| 1903 | 2048 | }; |
| 1904 | 2049 | for (const value of [ |
| 1905 | detail, | |
| 2050 | coreResponse(detail), | |
| 1906 | 2051 | [ |
| 1907 | 2052 | { |
| 1908 | 2053 | number: 7, |
| ... | ... | @@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1912 | 2057 | }, |
| 1913 | 2058 | ], |
| 1914 | 2059 | [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], |
| 1915 | { ...detail, headRefOid: "def456" }, | |
| 2060 | coreResponse({ ...detail, headRefOid: "def456" }), | |
| 1916 | 2061 | ]) { |
| 1917 | 2062 | mockedExecute.mockReturnValueOnce( |
| 1918 | 2063 | Effect.succeed( |
| ... | ... | @@ -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 | 2129 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => |
| 1947 | 2130 | Effect.gen(function* () { |
| 2131 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); | |
| 1948 | 2132 | mockedExecute.mockReturnValueOnce( |
| 1949 | 2133 | Effect.succeed( |
| 1950 | 2134 | output( |
| ... | ... | @@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 1983 | 2167 | limit: 1_000, |
| 1984 | 2168 | }); |
| 1985 | 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 | 2176 | mockedExecute.mockReturnValueOnce( |
| 1993 | 2177 | Effect.succeed( |
| 1994 | 2178 | output( |
| 1995 | // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. | |
| 1996 | JSON.stringify({ | |
| 1997 | number: 7, | |
| 1998 | title: "Pull request 7", | |
| 1999 | url: "https://github.com/acme/web/pull/7", | |
| 2000 | headRefName: "feat/page", | |
| 2001 | headRefOid: "abc123", | |
| 2002 | isCrossRepository: true, | |
| 2003 | headRepositoryOwner: null, | |
| 2004 | baseRefName: "main", | |
| 2005 | createdAt: "2026-07-01T00:00:00Z", | |
| 2006 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2007 | }), | |
| 2179 | encodeJson( | |
| 2180 | coreResponse({ | |
| 2181 | number: 7, | |
| 2182 | title: "Pull request 7", | |
| 2183 | url: "https://github.com/acme/web/pull/7", | |
| 2184 | headRefName: "feat/page", | |
| 2185 | headRefOid: "abc123", | |
| 2186 | isCrossRepository: true, | |
| 2187 | headRepositoryOwner: null, | |
| 2188 | baseRefName: "main", | |
| 2189 | createdAt: "2026-07-01T00:00:00Z", | |
| 2190 | updatedAt: "2026-07-02T00:00:00Z", | |
| 2191 | }), | |
| 2192 | ), | |
| 2008 | 2193 | ), |
| 2009 | 2194 | ), |
| 2010 | 2195 | ); |
| ... | ... | @@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3041 | 3226 | mockedExecute.mockReturnValueOnce( |
| 3042 | 3227 | Effect.succeed( |
| 3043 | 3228 | output( |
| 3044 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 3045 | JSON.stringify({ | |
| 3046 | number: 7, | |
| 3047 | title: "Progressive detail", | |
| 3048 | url: "https://github.com/acme/web/pull/7", | |
| 3049 | author: { login: "octocat" }, | |
| 3050 | headRefName: "feature", | |
| 3051 | baseRefName: "main", | |
| 3052 | createdAt: "2026-07-01T00:00:00Z", | |
| 3053 | updatedAt: "2026-07-02T00:00:00Z", | |
| 3054 | body: "Core body", | |
| 3055 | changedFiles: 2, | |
| 3056 | }), | |
| 3229 | encodeJson( | |
| 3230 | coreResponse({ | |
| 3231 | number: 7, | |
| 3232 | title: "Progressive detail", | |
| 3233 | url: "https://github.com/acme/web/pull/7", | |
| 3234 | author: { login: "octocat" }, | |
| 3235 | headRefName: "feature", | |
| 3236 | baseRefName: "main", | |
| 3237 | createdAt: "2026-07-01T00:00:00Z", | |
| 3238 | updatedAt: "2026-07-02T00:00:00Z", | |
| 3239 | body: "Core body", | |
| 3240 | changedFiles: 2, | |
| 3241 | }), | |
| 3242 | ), | |
| 3057 | 3243 | ), |
| 3058 | 3244 | ), |
| 3059 | 3245 | ); |
| ... | ... | @@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3083 | 3269 | |
| 3084 | 3270 | expect(detail.body).toBe("Core body"); |
| 3085 | 3271 | expect(activity.author?.login).toBe("octocat"); |
| 3086 | expect(callAt(0).args.at(-1)).toBe( | |
| 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", | |
| 3088 | ); | |
| 3272 | expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); | |
| 3273 | expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); | |
| 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 | 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 | 3482 | it.effect("fails a files page too large to read rather than calling the diff whole", () => |
| 3094 | 3483 | Effect.gen(function* () { |
| 3095 | 3484 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| ... | ... | @@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3271 | 3660 | JSON.stringify({ |
| 3272 | 3661 | data: { |
| 3273 | 3662 | repository: { |
| 3663 | mergeCommitAllowed: true, | |
| 3664 | squashMergeAllowed: false, | |
| 3665 | rebaseMergeAllowed: true, | |
| 3274 | 3666 | viewerPermission: "READ", |
| 3275 | 3667 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 3276 | 3668 | }, |
| ... | ... | @@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3291 | 3683 | // One request, because both answers hang off the same repository object. |
| 3292 | 3684 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3293 | 3685 | expect(callAt(0).args).toContain("number=7"); |
| 3686 | expect(callAt(0).args.at(-1)).toContain( | |
| 3687 | "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", | |
| 3688 | ); | |
| 3294 | 3689 | expect(access).toEqual({ |
| 3690 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 3295 | 3691 | canWrite: false, |
| 3296 | 3692 | canTriage: false, |
| 3297 | 3693 | canUpdate: true, |
| ... | ... | @@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3433 | 3829 | JSON.stringify({ |
| 3434 | 3830 | data: { |
| 3435 | 3831 | repository: { |
| 3832 | mergeCommitAllowed: true, | |
| 3833 | squashMergeAllowed: false, | |
| 3834 | rebaseMergeAllowed: true, | |
| 3436 | 3835 | viewerPermission: "READ", |
| 3437 | 3836 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 3438 | 3837 | }, |
| ... | ... | @@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3460 | 3859 | |
| 3461 | 3860 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3462 | 3861 | expect(access).toEqual({ |
| 3862 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 3463 | 3863 | canWrite: false, |
| 3464 | 3864 | canTriage: false, |
| 3465 | 3865 | canUpdate: true, |
| ... | ... | @@ -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 | 3872 | it.effect("asks GitHub to review, naming the collection a request is added to", () => |
| 3505 | 3873 | Effect.gen(function* () { |
| 3506 | 3874 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+182-134| ... | ... | @@ -30,6 +30,7 @@ import { |
| 30 | 30 | type PullRequestLabelCandidateList, |
| 31 | 31 | type PullRequestThreadCommentsResult, |
| 32 | 32 | type PullRequestUpdateMethod, |
| 33 | type PullRequestPreview, | |
| 33 | 34 | } from "@t3tools/contracts"; |
| 34 | 35 | |
| 35 | 36 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| ... | ... | @@ -44,6 +45,11 @@ import { |
| 44 | 45 | decodeActorAvatarsJson, |
| 45 | 46 | decodePullRequestActivityJson, |
| 46 | 47 | decodePullRequestDetailJson, |
| 48 | decodePullRequestCoreJson, | |
| 49 | PULL_REQUEST_CORE_GRAPHQL_QUERY, | |
| 50 | type GitHubPullRequestCore, | |
| 51 | decodePullRequestPreviewJson, | |
| 52 | PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, | |
| 47 | 53 | decodePullRequestFilesJson, |
| 48 | 54 | decodePullRequestFilesViewedJson, |
| 49 | 55 | decodePullRequestHeadsJson, |
| ... | ... | @@ -53,7 +59,6 @@ import { |
| 53 | 59 | decodePullRequestStacksJson, |
| 54 | 60 | decodePullRequestStatsJson, |
| 55 | 61 | decodeReactionSubjectScopeJson, |
| 56 | decodeRepositoryAccessJson, | |
| 57 | 62 | decodeReviewerCandidatesJson, |
| 58 | 63 | decodeLabelCandidatesJson, |
| 59 | 64 | buildLabelRequestJson, |
| ... | ... | @@ -78,7 +83,6 @@ import { |
| 78 | 83 | REMOVE_REACTION_GRAPHQL_MUTATION, |
| 79 | 84 | REVERT_PULL_REQUEST_GRAPHQL_MUTATION, |
| 80 | 85 | gitHubReactionContent, |
| 81 | REPOSITORY_ACCESS_JSON_FIELDS, | |
| 82 | 86 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, |
| 83 | 87 | REVIEWER_CANDIDATES_GRAPHQL_QUERY, |
| 84 | 88 | REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, |
| ... | ... | @@ -94,7 +98,6 @@ import { |
| 94 | 98 | decodeViewerPermissionsJson, |
| 95 | 99 | decodeWorkflowRunApprovalsJson, |
| 96 | 100 | type GitHubBaseComparison, |
| 97 | type GitHubPullRequestDetail, | |
| 98 | 101 | type GitHubPullRequestActivity, |
| 99 | 102 | type GitHubPullRequestHead, |
| 100 | 103 | type GitHubPullRequestListItem, |
| ... | ... | @@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service< |
| 519 | 522 | readonly repository: string; |
| 520 | 523 | readonly host: string; |
| 521 | 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 | 537 | readonly listWorkflowRunsRequiringApproval: (input: { |
| 525 | 538 | readonly cwd: string; |
| ... | ... | @@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service< |
| 639 | 652 | readonly cursor: string; |
| 640 | 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 | 655 | /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ |
| 650 | 656 | readonly getViewerAccess: (input: { |
| 651 | 657 | readonly cwd: string; |
| ... | ... | @@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service< |
| 654 | 660 | readonly number: number; |
| 655 | 661 | /** Manual action checks may use the quota held back from automatic reads. */ |
| 656 | 662 | readonly allowReserve?: boolean | undefined; |
| 657 | }) => Effect.Effect<GitHubViewerAccess, GitHubPullRequestCliError>; | |
| 663 | }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestCliError>; | |
| 658 | 664 | |
| 659 | 665 | /** Who this pull request may be sent to, and who it has already been sent to. */ |
| 660 | 666 | readonly listReviewerCandidates: (input: { |
| ... | ... | @@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () { |
| 1514 | 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 | 1526 | github |
| 1519 | 1527 | .execute({ |
| 1520 | 1528 | cwd: input.cwd, |
| ... | ... | @@ -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 | 1601 | const workflowApprovalLimit = 1_000; |
| 1547 | 1602 | const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); |
| 1548 | 1603 | const workflowApprovalReadError = (cwd: string, cause: unknown) => |
| ... | ... | @@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () { |
| 1554 | 1609 | }); |
| 1555 | 1610 | const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = |
| 1556 | 1611 | (input) => |
| 1557 | github | |
| 1558 | .execute({ | |
| 1559 | cwd: input.cwd, | |
| 1560 | args: [ | |
| 1561 | "pr", | |
| 1562 | "list", | |
| 1563 | ...repositoryArgs(input), | |
| 1564 | "--state", | |
| 1565 | "open", | |
| 1566 | "--head", | |
| 1567 | input.headBranch, | |
| 1568 | "--limit", | |
| 1569 | workflowApprovalProbeLimit, | |
| 1570 | "--json", | |
| 1571 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | |
| 1572 | ], | |
| 1573 | }) | |
| 1574 | .pipe( | |
| 1575 | Effect.flatMap( | |
| 1576 | ( | |
| 1577 | result, | |
| 1578 | ): Effect.Effect< | |
| 1579 | GitHubPullRequestHead, | |
| 1580 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1581 | > => { | |
| 1582 | const decoded = decodePullRequestHeadsJson(result.stdout.trim()); | |
| 1583 | if (!Result.isSuccess(decoded)) { | |
| 1584 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | |
| 1585 | } | |
| 1586 | const exactHeads = decoded.success.filter( | |
| 1587 | (pullRequest) => | |
| 1588 | pullRequest.headSha === input.headSha && | |
| 1589 | pullRequest.isCrossRepository === true && | |
| 1590 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1591 | input.headRepositoryOwner.toLowerCase(), | |
| 1592 | ); | |
| 1593 | if (decoded.success.length > workflowApprovalLimit) { | |
| 1594 | return Effect.fail( | |
| 1595 | new GitHubWorkflowApprovalRefusedError({ | |
| 1596 | command: "gh", | |
| 1597 | cwd: input.cwd, | |
| 1598 | number: input.number, | |
| 1599 | reason: "head-list-truncated", | |
| 1600 | observedCount: decoded.success.length, | |
| 1601 | limit: workflowApprovalLimit, | |
| 1602 | }), | |
| 1603 | ); | |
| 1604 | } | |
| 1605 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1606 | return Effect.fail( | |
| 1607 | new GitHubWorkflowApprovalRefusedError({ | |
| 1608 | command: "gh", | |
| 1609 | cwd: input.cwd, | |
| 1610 | number: input.number, | |
| 1611 | reason: "head-not-unique", | |
| 1612 | observedCount: exactHeads.length, | |
| 1613 | limit: workflowApprovalLimit, | |
| 1614 | }), | |
| 1615 | ); | |
| 1616 | } | |
| 1617 | return Effect.succeed(exactHeads[0]); | |
| 1618 | }, | |
| 1619 | ), | |
| 1620 | Effect.flatMap(() => | |
| 1621 | github.execute({ | |
| 1612 | Effect.all( | |
| 1613 | [ | |
| 1614 | github | |
| 1615 | .execute({ | |
| 1616 | cwd: input.cwd, | |
| 1617 | args: [ | |
| 1618 | "pr", | |
| 1619 | "list", | |
| 1620 | ...repositoryArgs(input), | |
| 1621 | "--state", | |
| 1622 | "open", | |
| 1623 | "--head", | |
| 1624 | input.headBranch, | |
| 1625 | "--limit", | |
| 1626 | workflowApprovalProbeLimit, | |
| 1627 | "--json", | |
| 1628 | "number,headRefOid,isCrossRepository,headRepositoryOwner", | |
| 1629 | ], | |
| 1630 | }) | |
| 1631 | .pipe( | |
| 1632 | Effect.flatMap( | |
| 1633 | ( | |
| 1634 | result, | |
| 1635 | ): Effect.Effect< | |
| 1636 | GitHubPullRequestHead, | |
| 1637 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1638 | > => { | |
| 1639 | const decoded = decodePullRequestHeadsJson(result.stdout.trim()); | |
| 1640 | if (!Result.isSuccess(decoded)) { | |
| 1641 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | |
| 1642 | } | |
| 1643 | const exactHeads = decoded.success.filter( | |
| 1644 | (pullRequest) => | |
| 1645 | pullRequest.headSha === input.headSha && | |
| 1646 | pullRequest.isCrossRepository === true && | |
| 1647 | pullRequest.headRepositoryOwner?.toLowerCase() === | |
| 1648 | input.headRepositoryOwner.toLowerCase(), | |
| 1649 | ); | |
| 1650 | if (decoded.success.length > workflowApprovalLimit) { | |
| 1651 | return Effect.fail( | |
| 1652 | new GitHubWorkflowApprovalRefusedError({ | |
| 1653 | command: "gh", | |
| 1654 | cwd: input.cwd, | |
| 1655 | number: input.number, | |
| 1656 | reason: "head-list-truncated", | |
| 1657 | observedCount: decoded.success.length, | |
| 1658 | limit: workflowApprovalLimit, | |
| 1659 | }), | |
| 1660 | ); | |
| 1661 | } | |
| 1662 | if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { | |
| 1663 | return Effect.fail( | |
| 1664 | new GitHubWorkflowApprovalRefusedError({ | |
| 1665 | command: "gh", | |
| 1666 | cwd: input.cwd, | |
| 1667 | number: input.number, | |
| 1668 | reason: "head-not-unique", | |
| 1669 | observedCount: exactHeads.length, | |
| 1670 | limit: workflowApprovalLimit, | |
| 1671 | }), | |
| 1672 | ); | |
| 1673 | } | |
| 1674 | return Effect.succeed(exactHeads[0]); | |
| 1675 | }, | |
| 1676 | ), | |
| 1677 | ), | |
| 1678 | github | |
| 1679 | .execute({ | |
| 1622 | 1680 | cwd: input.cwd, |
| 1623 | 1681 | args: [ |
| 1624 | 1682 | "run", |
| ... | ... | @@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () { |
| 1637 | 1695 | "--json", |
| 1638 | 1696 | "databaseId,workflowName,url", |
| 1639 | 1697 | ], |
| 1640 | }), | |
| 1641 | ), | |
| 1642 | Effect.flatMap( | |
| 1643 | ( | |
| 1644 | result, | |
| 1645 | ): Effect.Effect< | |
| 1646 | ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 1647 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1648 | > => { | |
| 1649 | const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); | |
| 1650 | if (!Result.isSuccess(decoded)) { | |
| 1651 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | |
| 1652 | } | |
| 1653 | return decoded.success.length > workflowApprovalLimit | |
| 1654 | ? Effect.fail( | |
| 1655 | new GitHubWorkflowApprovalRefusedError({ | |
| 1656 | command: "gh", | |
| 1657 | cwd: input.cwd, | |
| 1658 | number: input.number, | |
| 1659 | reason: "run-list-truncated", | |
| 1660 | observedCount: decoded.success.length, | |
| 1661 | limit: workflowApprovalLimit, | |
| 1662 | }), | |
| 1663 | ) | |
| 1664 | : Effect.succeed(decoded.success); | |
| 1665 | }, | |
| 1666 | ), | |
| 1667 | ); | |
| 1698 | }) | |
| 1699 | .pipe( | |
| 1700 | Effect.flatMap( | |
| 1701 | ( | |
| 1702 | result, | |
| 1703 | ): Effect.Effect< | |
| 1704 | ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 1705 | GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError | |
| 1706 | > => { | |
| 1707 | const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); | |
| 1708 | if (!Result.isSuccess(decoded)) { | |
| 1709 | return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); | |
| 1710 | } | |
| 1711 | return decoded.success.length > workflowApprovalLimit | |
| 1712 | ? Effect.fail( | |
| 1713 | new GitHubWorkflowApprovalRefusedError({ | |
| 1714 | command: "gh", | |
| 1715 | cwd: input.cwd, | |
| 1716 | number: input.number, | |
| 1717 | reason: "run-list-truncated", | |
| 1718 | observedCount: decoded.success.length, | |
| 1719 | limit: workflowApprovalLimit, | |
| 1720 | }), | |
| 1721 | ) | |
| 1722 | : Effect.succeed(decoded.success); | |
| 1723 | }, | |
| 1724 | ), | |
| 1725 | ), | |
| 1726 | ], | |
| 1727 | { concurrency: 2 }, | |
| 1728 | ).pipe(Effect.map(([, runs]) => runs)); | |
| 1668 | 1729 | |
| 1669 | 1730 | return GitHubPullRequestCli.of({ |
| 1670 | 1731 | withVerifiedCredential, |
| ... | ... | @@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () { |
| 1924 | 1985 | ), |
| 1925 | 1986 | |
| 1926 | 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 | 2003 | listWorkflowRunsRequiringApproval, |
| 1928 | 2004 | |
| 1929 | 2005 | getPullRequestStack: (input) => { |
| ... | ... | @@ -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 | 2325 | getViewerAccess: (input) => { |
| 2278 | 2326 | const { owner, name } = parseRepositorySelector(input.repository); |
| 2279 | 2327 | return graphqlRead({ |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+112-37| ... | ... | @@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 9 | 9 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 10 | 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 | 24 | it.effect("maps credential verification failures without relabeling operation failures", () => |
| 13 | 25 | Effect.gen(function* () { |
| 14 | 26 | let verificationFails = true; |
| ... | ... | @@ -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 | 245 | Effect.gen(function* () { |
| 234 | 246 | const provider = yield* make; |
| 235 | 247 | const detail = yield* provider.getChangeRequest({ |
| ... | ... | @@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => { |
| 276 | 288 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ |
| 277 | 289 | getPullRequestDetail: () => |
| 278 | 290 | Effect.succeed({ |
| 291 | ...coreFields, | |
| 292 | comparison: null, | |
| 293 | viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, | |
| 279 | 294 | authorId: null, |
| 280 | 295 | number: 7, |
| 281 | 296 | title: "Pull request 7", |
| ... | ... | @@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => { |
| 285 | 300 | headRepositoryOwner: null, |
| 286 | 301 | headBranch: "feat/page", |
| 287 | 302 | baseBranch: "main", |
| 288 | state: "open", | |
| 303 | state: "open" as const, | |
| 289 | 304 | isDraft: false, |
| 290 | mergeability: "mergeable", | |
| 305 | mergeability: "mergeable" as const, | |
| 291 | 306 | reviewDecision: null, |
| 292 | 307 | additions: 1, |
| 293 | 308 | deletions: 1, |
| ... | ... | @@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => { |
| 304 | 319 | checks: [], |
| 305 | 320 | comments: [], |
| 306 | 321 | commits: [], |
| 307 | }), | |
| 308 | getRepositoryAccess: () => | |
| 309 | GitHubCli.PinnedGitHubCredential.pipe( | |
| 310 | Effect.map((credential) => ({ | |
| 311 | canWrite: false, | |
| 312 | mergeCapabilities: { | |
| 313 | merge: true, | |
| 314 | squash: credential?.credentialFingerprint !== "restricted", | |
| 315 | rebase: true, | |
| 316 | }, | |
| 317 | })), | |
| 322 | }).pipe( | |
| 323 | Effect.flatMap((detail) => | |
| 324 | GitHubCli.PinnedGitHubCredential.pipe( | |
| 325 | Effect.map((credential) => ({ | |
| 326 | ...detail, | |
| 327 | viewerAccess: { | |
| 328 | ...detail.viewerAccess, | |
| 329 | mergeCapabilities: { | |
| 330 | ...detail.viewerAccess.mergeCapabilities, | |
| 331 | squash: credential?.credentialFingerprint !== "restricted", | |
| 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 | 376 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ |
| 365 | 377 | getPullRequestDetail: () => |
| 366 | 378 | Effect.succeed({ |
| 379 | ...coreFields, | |
| 367 | 380 | authorId: null, |
| 368 | 381 | number: 7, |
| 369 | 382 | title: "Pull request 7", |
| ... | ... | @@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => { |
| 412 | 425 | ]), |
| 413 | 426 | getPullRequestBaseComparison: () => |
| 414 | 427 | Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 415 | getRepositoryAccess: () => | |
| 428 | getViewerAccess: () => | |
| 416 | 429 | Effect.succeed({ |
| 417 | 430 | canWrite: true, |
| 431 | canTriage: true, | |
| 432 | canUpdate: true, | |
| 433 | didAuthor: false, | |
| 418 | 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 | 440 | }); |
| 427 | 441 | |
| 428 | 442 | const openDetail = { |
| 443 | ...coreFields, | |
| 429 | 444 | authorId: null, |
| 430 | 445 | number: 7, |
| 431 | 446 | title: "Pull request 7", |
| ... | ... | @@ -457,6 +472,39 @@ const openDetail = { |
| 457 | 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 | 508 | it.effect("does not classify same-repository gates as fork workflow approvals", () => |
| 461 | 509 | Effect.gen(function* () { |
| 462 | 510 | const provider = yield* make; |
| ... | ... | @@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals", |
| 476 | 524 | getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), |
| 477 | 525 | listWorkflowRunsRequiringApproval: () => |
| 478 | 526 | Effect.die("same-repository pull requests must not probe fork workflow approvals"), |
| 479 | getRepositoryAccess: () => | |
| 527 | getViewerAccess: () => | |
| 480 | 528 | Effect.succeed({ |
| 481 | 529 | canWrite: true, |
| 530 | canTriage: true, | |
| 531 | canUpdate: true, | |
| 532 | didAuthor: false, | |
| 482 | 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 | 572 | limit: 1_000, |
| 524 | 573 | }), |
| 525 | 574 | ), |
| 526 | getRepositoryAccess: () => | |
| 575 | getViewerAccess: () => | |
| 527 | 576 | Effect.succeed({ |
| 528 | 577 | canWrite: true, |
| 578 | canTriage: true, | |
| 579 | canUpdate: true, | |
| 580 | didAuthor: false, | |
| 529 | 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 | 612 | cause: new Error("rate limited"), |
| 563 | 613 | }), |
| 564 | 614 | ), |
| 565 | getRepositoryAccess: () => | |
| 615 | getViewerAccess: () => | |
| 566 | 616 | Effect.succeed({ |
| 567 | 617 | canWrite: true, |
| 618 | canTriage: true, | |
| 619 | canUpdate: true, | |
| 620 | didAuthor: false, | |
| 568 | 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 | 649 | getViewerAccess: () => |
| 599 | 650 | Effect.sync(() => { |
| 600 | 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 | 672 | getPullRequestDetail: () => Effect.succeed(openDetail), |
| 616 | 673 | getPullRequestBaseComparison: () => comparison, |
| 617 | 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 | 684 | it.effect("offers update-branch when the comparison grants it", () => |
| ... | ... | @@ -661,7 +724,13 @@ describe("getViewerPermissions", () => { |
| 661 | 724 | getViewerAccess: (input) => |
| 662 | 725 | Effect.sync(() => { |
| 663 | 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 | 765 | }), |
| 697 | 766 | ), |
| 698 | 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 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 3 | 1 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | 2 | import type { |
| 6 | 3 | PullRequestActor, |
| 7 | 4 | PullRequestCapabilities, |
| ... | ... | @@ -11,7 +8,6 @@ import type { |
| 11 | 8 | } from "@t3tools/contracts"; |
| 12 | 9 | |
| 13 | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 14 | import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts"; | |
| 15 | 11 | import { |
| 16 | 12 | PullRequestProviderError, |
| 17 | 13 | type PullRequestProviderFailure, |
| ... | ... | @@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean => |
| 193 | 189 | export const make = Effect.gen(function* () { |
| 194 | 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 | 192 | const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => |
| 226 | 193 | new PullRequestProviderError({ |
| 227 | 194 | provider: "github", |
| ... | ... | @@ -340,102 +307,80 @@ export const make = Effect.gen(function* () { |
| 340 | 307 | getChangeRequestStack: (input) => |
| 341 | 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 | 313 | getChangeRequest: (input) => |
| 344 | Effect.all( | |
| 345 | [ | |
| 346 | cli.getPullRequestDetail(input).pipe( | |
| 347 | Effect.flatMap((pullRequest) => | |
| 348 | Effect.all({ | |
| 349 | // Only an open pull request can be behind anything worth saying so about, and | |
| 350 | // only one whose head repository is known can be compared at all. | |
| 351 | comparison: | |
| 352 | pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null | |
| 353 | ? Effect.succeed(null) | |
| 354 | : cli | |
| 355 | .getPullRequestBaseComparison({ | |
| 356 | ...input, | |
| 357 | headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, | |
| 358 | }) | |
| 359 | .pipe(Effect.orElseSucceed(() => null)), | |
| 360 | // GitHub omits a fork workflow that has not been approved from the normal check | |
| 361 | // rollup. Read the action-required runs by head revision so "all passed" cannot | |
| 362 | // be shown while a whole workflow is still waiting to start. | |
| 363 | workflowApprovals: | |
| 364 | pullRequest.state !== "open" || pullRequest.isCrossRepository !== true | |
| 365 | ? Effect.succeed({ | |
| 366 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 367 | unavailable: false, | |
| 368 | }) | |
| 369 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null | |
| 370 | ? Effect.succeed({ | |
| 371 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 372 | unavailable: true, | |
| 373 | }) | |
| 374 | : cli | |
| 375 | .listWorkflowRunsRequiringApproval({ | |
| 376 | ...input, | |
| 377 | headSha: pullRequest.headSha, | |
| 378 | headBranch: pullRequest.headBranch, | |
| 379 | headRepositoryOwner: pullRequest.headRepositoryOwner, | |
| 380 | isCrossRepository: true, | |
| 381 | }) | |
| 382 | .pipe( | |
| 383 | Effect.matchEffect({ | |
| 384 | onFailure: (error) => | |
| 385 | error._tag === "GitHubCliRateLimitError" || | |
| 386 | error._tag === "SourceControlRateLimitPausedError" | |
| 387 | ? Effect.fail(error) | |
| 388 | : Effect.succeed({ | |
| 389 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 390 | unavailable: true, | |
| 391 | }), | |
| 392 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), | |
| 393 | }), | |
| 394 | ), | |
| 395 | }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))), | |
| 396 | ), | |
| 397 | ), | |
| 398 | getRepositoryAccess({ | |
| 399 | cwd: input.cwd, | |
| 400 | repository: input.repository, | |
| 401 | host: input.host, | |
| 402 | }), | |
| 403 | // A small permissions query replaces the deeply paginated review-thread walk on the | |
| 404 | // core path. Writes ask again immediately before mutating, so this is presentation. | |
| 405 | cli.getViewerAccess(input), | |
| 406 | ], | |
| 407 | { concurrency: 3 }, | |
| 408 | ).pipe( | |
| 314 | cli.getPullRequestDetail(input).pipe( | |
| 315 | Effect.flatMap((pullRequest) => { | |
| 316 | // Fork workflows awaiting approval are absent from the normal check rollup. | |
| 317 | const approvals = | |
| 318 | pullRequest.state !== "open" || pullRequest.isCrossRepository !== true | |
| 319 | ? Effect.succeed({ | |
| 320 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 321 | unavailable: false, | |
| 322 | }) | |
| 323 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null | |
| 324 | ? Effect.succeed({ | |
| 325 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 326 | unavailable: true, | |
| 327 | }) | |
| 328 | : cli | |
| 329 | .listWorkflowRunsRequiringApproval({ | |
| 330 | ...input, | |
| 331 | headSha: pullRequest.headSha, | |
| 332 | headBranch: pullRequest.headBranch, | |
| 333 | headRepositoryOwner: pullRequest.headRepositoryOwner, | |
| 334 | isCrossRepository: true, | |
| 335 | }) | |
| 336 | .pipe( | |
| 337 | Effect.matchEffect({ | |
| 338 | onFailure: (error) => | |
| 339 | error._tag === "GitHubCliRateLimitError" || | |
| 340 | error._tag === "SourceControlRateLimitPausedError" | |
| 341 | ? Effect.fail(error) | |
| 342 | : Effect.succeed({ | |
| 343 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 344 | unavailable: true, | |
| 345 | }), | |
| 346 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), | |
| 347 | }), | |
| 348 | ); | |
| 349 | return approvals.pipe( | |
| 350 | Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({ | |
| 351 | ...pullRequest, | |
| 352 | author: withAvatar(pullRequest.author, new Map<string, string>(), input.host), | |
| 353 | checks: withWorkflowApprovals( | |
| 354 | pullRequest.checks, | |
| 355 | workflowApprovals.runs, | |
| 356 | workflowApprovals.unavailable, | |
| 357 | ), | |
| 358 | ...(workflowApprovals.unavailable | |
| 359 | ? {} | |
| 360 | : { workflowApprovalsRequired: workflowApprovals.runs.length }), | |
| 361 | reviewers: pullRequest.reviewRequestLogins.map((login) => ({ | |
| 362 | login, | |
| 363 | name: null, | |
| 364 | avatarUrl: null, | |
| 365 | })), | |
| 366 | mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, | |
| 367 | viewerPermissions: gitHubViewerPermissions({ | |
| 368 | ...pullRequest.viewerAccess, | |
| 369 | canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, | |
| 370 | }), | |
| 371 | baseComparison: | |
| 372 | pullRequest.comparison === null || pullRequest.comparison.behindBy === null | |
| 373 | ? "unknown" | |
| 374 | : pullRequest.comparison.behindBy > 0 | |
| 375 | ? "behind" | |
| 376 | : "up-to-date", | |
| 377 | ...(pullRequest.comparison?.behindBy == null | |
| 378 | ? {} | |
| 379 | : { behindBy: pullRequest.comparison.behindBy }), | |
| 380 | })), | |
| 381 | ); | |
| 382 | }), | |
| 409 | 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 | 386 | getChangeRequestActivity: (input) => |
apps/server/src/pullRequest/PullRequestProvider.ts+9| ... | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | 31 | PullRequestReviewerKind, |
| 32 | 32 | PullRequestLabelCandidateList, |
| 33 | 33 | PullRequestState, |
| 34 | PullRequestPreview, | |
| 34 | 35 | PullRequestUpdateMethod, |
| 35 | 36 | PullRequestViewerPermissions, |
| 36 | 37 | SourceControlProviderKind, |
| ... | ... | @@ -400,6 +401,14 @@ export interface PullRequestProviderApi { |
| 400 | 401 | input: ProviderRepositoryRef & { readonly number: number }, |
| 401 | 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 | 413 | * The cheap live fields used by linked threads. Optional because a provider without a narrow |
| 405 | 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 | 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 | 328 | function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { |
| 120 | 329 | return new PullRequestProviderError({ |
| 121 | 330 | provider, |
| ... | ... | @@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after |
| 1840 | 2049 | state: "open", |
| 1841 | 2050 | }); |
| 1842 | 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 | 2059 | const detail = yield* service.detail({ |
| 1844 | 2060 | projectId: "ssh" as ProjectId, |
| 1845 | 2061 | host: "code.example:3000", |
| ... | ... | @@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => |
| 4071 | 4287 | |
| 4072 | 4288 | it.effect("isolates routed caches for two credentials belonging to the same account", () => |
| 4073 | 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 | 4291 | let credential = "broad"; |
| 4076 | 4292 | let calls = 0; |
| 4077 | 4293 | const read = () => |
| ... | ... | @@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco |
| 4109 | 4325 | read().pipe( |
| 4110 | 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 | 32 | type PullRequestCommentInput, |
| 33 | 33 | type PullRequestCommentUpdateInput, |
| 34 | 34 | type PullRequestDetail, |
| 35 | type PullRequestPreview, | |
| 35 | 36 | type PullRequestDiffFileContentsInput, |
| 36 | 37 | type PullRequestDiffFileContentsResult, |
| 37 | 38 | type PullRequestDiffStat, |
| ... | ... | @@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service< |
| 202 | 203 | readonly subscribeRefreshes: Stream.Stream<number>; |
| 203 | 204 | readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>; |
| 204 | 205 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; |
| 206 | readonly preview: ( | |
| 207 | input: PullRequestRef, | |
| 208 | ) => Effect.Effect<PullRequestPreview, PullRequestError>; | |
| 205 | 209 | readonly activity: ( |
| 206 | 210 | input: PullRequestRef, |
| 207 | 211 | ) => Effect.Effect<PullRequestActivity, PullRequestError>; |
| ... | ... | @@ -546,6 +550,9 @@ function withRateLimitBackoff( |
| 546 | 550 | listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), |
| 547 | 551 | }), |
| 548 | 552 | getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), |
| 553 | ...(api.getChangeRequestPreview === undefined | |
| 554 | ? {} | |
| 555 | : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), | |
| 549 | 556 | ...(api.getChangeRequestSummary === undefined |
| 550 | 557 | ? {} |
| 551 | 558 | : { |
| ... | ... | @@ -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 | 1721 | const activityUncached: PullRequestService["Service"]["activity"] = (input) => |
| 1683 | 1722 | requireProject(input).pipe( |
| 1684 | 1723 | Effect.flatMap((project) => |
| ... | ... | @@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () { |
| 2885 | 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 | 2948 | const activity: PullRequestService["Service"]["activity"] = (input) => { |
| 2889 | 2949 | const key = refCacheKey(input); |
| 2890 | 2950 | return Cache.get(activityCache, key); |
| ... | ... | @@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () { |
| 3131 | 3191 | refreshAfterTurn, |
| 3132 | 3192 | detail: credentialCached(detail), |
| 3133 | 3193 | activity: credentialCached(activity), |
| 3194 | preview: credentialCached(preview), | |
| 3134 | 3195 | threadComments, |
| 3135 | 3196 | diff: credentialCached(diff), |
| 3136 | 3197 | diffFileContents, |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+43-15| ... | ... | @@ -17,7 +17,6 @@ import { |
| 17 | 17 | decodePullRequestSearchJson, |
| 18 | 18 | decodePullRequestStacksJson, |
| 19 | 19 | decodeLabelCandidatesJson, |
| 20 | decodeRepositoryAccessJson, | |
| 21 | 20 | decodeReviewerCandidatesJson, |
| 22 | 21 | decodeReviewThreadCommentsJson, |
| 23 | 22 | decodeReviewThreadsJson, |
| ... | ... | @@ -810,31 +809,38 @@ describe("reaction decoding", () => { |
| 810 | 809 | describe("repository access decoding", () => { |
| 811 | 810 | const repositoryJson = (viewerPermission?: string | null) => |
| 812 | 811 | JSON.stringify({ |
| 813 | mergeCommitAllowed: true, | |
| 814 | squashMergeAllowed: false, | |
| 815 | rebaseMergeAllowed: true, | |
| 816 | ...(viewerPermission === undefined ? {} : { viewerPermission }), | |
| 812 | data: { | |
| 813 | repository: { | |
| 814 | pullRequest: null, | |
| 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 | 824 | expect( |
| 821 | expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities, | |
| 825 | expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, | |
| 822 | 826 | ).toEqual({ merge: true, squash: false, rebase: true }); |
| 823 | 827 | }); |
| 824 | 828 | |
| 825 | 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 | 833 | expect(Result.isSuccess(decoded)).toBe(false); |
| 828 | 834 | }); |
| 829 | 835 | |
| 830 | 836 | it("counts the roles that can push as write, and the ones that cannot as read", () => { |
| 831 | 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 | 839 | true, |
| 834 | 840 | ); |
| 835 | 841 | } |
| 836 | 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 | 844 | false, |
| 839 | 845 | ); |
| 840 | 846 | } |
| ... | ... | @@ -843,14 +849,23 @@ describe("repository access decoding", () => { |
| 843 | 849 | it("withholds write where gh names no permission, which is not a standing it gave", () => { |
| 844 | 850 | // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes |
| 845 | 851 | // the press, where a missing one still leaves the pull request open on its host. |
| 846 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false); | |
| 847 | expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false); | |
| 852 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); | |
| 853 | expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); | |
| 848 | 854 | }); |
| 849 | 855 | }); |
| 850 | 856 | |
| 851 | 857 | describe("viewer permission decoding", () => { |
| 852 | 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 | 870 | it("reads the repository's role and the pull request's own viewer fields together", () => { |
| 856 | 871 | expect( |
| ... | ... | @@ -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 | 889 | it("says no to a passer-by on a repository they can only read", () => { |
| ... | ... | @@ -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 | 908 | it("reads silence as permission, but not as authorship", () => { |
| ... | ... | @@ -883,6 +910,7 @@ describe("viewer permission decoding", () => { |
| 883 | 910 | // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, |
| 884 | 911 | // and claiming it for someone who did not is how an author's own rules get handed out. |
| 885 | 912 | expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ |
| 913 | mergeCapabilities: { merge: true, squash: false, rebase: true }, | |
| 886 | 914 | canWrite: false, |
| 887 | 915 | canTriage: false, |
| 888 | 916 | canUpdate: true, |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+187-35| ... | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | 5 | import type { |
| 6 | 6 | PullRequestStackMembership, |
| 7 | 7 | PullRequestActor, |
| 8 | PullRequestPreview, | |
| 8 | 9 | PullRequestCheck, |
| 9 | 10 | PullRequestCheckStatus, |
| 10 | 11 | PullRequestChecksState, |
| ... | ... | @@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({ |
| 596 | 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 | 656 | const RawPullRequestFileSchema = Schema.Struct({ |
| 600 | 657 | filename: Schema.String, |
| 601 | 658 | status: Schema.optional(Schema.NullOr(Schema.String)), |
| ... | ... | @@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = |
| 644 | 701 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; |
| 645 | 702 | |
| 646 | 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 | 776 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; |
| 648 | 777 | |
| 649 | 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 | 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 | 1166 | export interface GitHubPullRequestListItem { |
| 1046 | 1167 | readonly stack?: PullRequestStackMembership; |
| 1047 | 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 | 1610 | const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); |
| 1490 | 1611 | const decodeActivity = decodeJsonResult(RawActivitySchema); |
| 1491 | 1612 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); |
| 1492 | const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); | |
| 1493 | 1613 | const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); |
| 1494 | 1614 | const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); |
| 1495 | 1615 | |
| ... | ... | @@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson( |
| 1689 | 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 | 1860 | export function decodePullRequestDetailJson( |
| 1693 | 1861 | raw: string, |
| 1694 | 1862 | ): Result.Result<GitHubPullRequestDetail, DecodeFailure> { |
| ... | ... | @@ -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 | 2224 | export interface GitHubRepositoryAccess { |
| 2057 | 2225 | readonly mergeCapabilities: PullRequestMergeCapabilities; |
| 2058 | 2226 | readonly canWrite: boolean; |
| ... | ... | @@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { |
| 2084 | 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 | 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 | 2580 | readonly canUpdateBranch?: boolean; |
| 2429 | 2581 | } |
| 2430 | 2582 | |
| 2431 | /** | |
| 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 | */ | |
| 2583 | /** Core detail and write checks share one read of permissions and merge settings. */ | |
| 2437 | 2584 | export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { |
| 2438 | 2585 | repository(owner: $owner, name: $name) { |
| 2439 | viewerPermission | |
| 2586 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | |
| 2440 | 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 | 2591 | const RawViewerPermissionsSchema = Schema.Struct({ |
| 2445 | 2592 | data: Schema.Struct({ |
| 2446 | 2593 | repository: Schema.Struct({ |
| 2447 | viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), | |
| 2594 | ...RawRepositoryAccessSchema.fields, | |
| 2448 | 2595 | /** Null for a number that names no pull request the viewer can see. */ |
| 2449 | 2596 | pullRequest: Schema.NullOr(RawViewerFieldsSchema), |
| 2450 | 2597 | }), |
| ... | ... | @@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); |
| 2455 | 2602 | |
| 2456 | 2603 | export function decodeViewerPermissionsJson( |
| 2457 | 2604 | raw: string, |
| 2458 | ): Result.Result<GitHubViewerAccess, DecodeFailure> { | |
| 2605 | ): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> { | |
| 2459 | 2606 | const decoded = decodeViewerPermissions(raw); |
| 2460 | 2607 | if (!Result.isSuccess(decoded)) { |
| 2461 | 2608 | return Result.fail(decoded.failure); |
| 2462 | 2609 | } |
| 2463 | 2610 | const repository = decoded.success.data.repository; |
| 2464 | 2611 | return Result.succeed({ |
| 2612 | mergeCapabilities: { | |
| 2613 | merge: repository.mergeCommitAllowed, | |
| 2614 | squash: repository.squashMergeAllowed, | |
| 2615 | rebase: repository.rebaseMergeAllowed, | |
| 2616 | }, | |
| 2465 | 2617 | canWrite: toCanWrite(repository.viewerPermission), |
| 2466 | 2618 | canTriage: toCanTriage(repository.viewerPermission), |
| 2467 | 2619 | ...toPullRequestViewerFields(repository.pullRequest), |
apps/server/src/ws.ts+6| ... | ... | @@ -2729,6 +2729,12 @@ const makeWsRpcLayer = ( |
| 2729 | 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 | 2738 | [WS_METHODS.pullRequestsActivity]: (input) => |
| 2733 | 2739 | observeRpcEffect( |
| 2734 | 2740 | WS_METHODS.pullRequestsActivity, |
apps/web/src/components/ChatMarkdown.tsx+1| ... | ... | @@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2905 | 2905 | environmentId, |
| 2906 | 2906 | input: { |
| 2907 | 2907 | projectId: pullRequestProject.id, |
| 2908 | host: pullRequestCandidate.authority ?? pullRequestCandidate.host, | |
| 2908 | 2909 | repository: |
| 2909 | 2910 | sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? |
| 2910 | 2911 | pullRequestCandidate.repository, |
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+32-31| ... | ... | @@ -42,11 +42,10 @@ export function PullRequestLinkPreview({ |
| 42 | 42 | }) |
| 43 | 43 | : null, |
| 44 | 44 | ); |
| 45 | const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, { | |
| 45 | const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { | |
| 46 | 46 | reportFailure: false, |
| 47 | 47 | reportDefect: false, |
| 48 | 48 | }); |
| 49 | ||
| 50 | 49 | const trigger = confirmBeforeOpen |
| 51 | 50 | ? cloneElement(link, { |
| 52 | 51 | onClick: (event) => { |
| ... | ... | @@ -56,7 +55,7 @@ export function PullRequestLinkPreview({ |
| 56 | 55 | if (resolvingClick) return; |
| 57 | 56 | setOpen(false); |
| 58 | 57 | setResolvingClick(true); |
| 59 | void readDetail({ environmentId: target.environmentId, input: target.input }) | |
| 58 | void readPreview(target) | |
| 60 | 59 | .then(async (result) => { |
| 61 | 60 | if (isAtomCommandInterrupted(result)) return; |
| 62 | 61 | if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; |
| ... | ... | @@ -84,36 +83,38 @@ export function PullRequestLinkPreview({ |
| 84 | 83 | return ( |
| 85 | 84 | <PreviewCard open={open} onOpenChange={setOpen}> |
| 86 | 85 | <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} /> |
| 87 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> | |
| 88 | {detail === null ? ( | |
| 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> | |
| 90 | {detailQuery.isPending ? "Loading pull request details…" : originalUrl} | |
| 91 | </p> | |
| 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} | |
| 86 | {detail !== null || detailQuery.error !== null ? ( | |
| 87 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> | |
| 88 | {detail === null ? ( | |
| 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> | |
| 90 | {originalUrl} | |
| 107 | 91 | </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> | |
| 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> | |
| 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 | 114 | </div> |
| 114 | </div> | |
| 115 | )} | |
| 116 | </PreviewCardPopup> | |
| 115 | )} | |
| 116 | </PreviewCardPopup> | |
| 117 | ) : null} | |
| 117 | 118 | </PreviewCard> |
| 118 | 119 | ); |
| 119 | 120 | } |
knip.jsonc+1| ... | ... | @@ -23,6 +23,7 @@ |
| 23 | 23 | "src/claude-history-worker.ts!", |
| 24 | 24 | "scripts/cli.ts", |
| 25 | 25 | "scripts/evaluate-thread-titles.ts", |
| 26 | "scripts/measure-pr-preview.ts", | |
| 26 | 27 | "src/provider/testFixtures/*.mjs", |
| 27 | 28 | ], |
| 28 | 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 | 29 | WS_METHODS.pullRequestsSummary, |
| 30 | 30 | WS_METHODS.pullRequestsStack, |
| 31 | 31 | WS_METHODS.pullRequestsDetail, |
| 32 | WS_METHODS.pullRequestsPreview, | |
| 32 | 33 | WS_METHODS.pullRequestsActivity, |
| 33 | 34 | WS_METHODS.pullRequestsThreadComments, |
| 34 | 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 | 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 | 968 | it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => |
| 891 | 969 | Effect.scoped( |
| 892 | 970 | Effect.gen(function* () { |
packages/client-runtime/src/state/pullRequests.ts+18| ... | ... | @@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 176 | 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 | 188 | const labelCandidates = writableQueryFamily( |
| 180 | 189 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 181 | 190 | label: "environment-data:pull-requests:label-candidates", |
| ... | ... | @@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 221 | 230 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), |
| 222 | 231 | }), |
| 223 | 232 | detail, |
| 233 | preview, | |
| 224 | 234 | activity, |
| 225 | 235 | threadComments: createEnvironmentRpcCommand(runtime, { |
| 226 | 236 | label: "environment-data:pull-requests:thread-comments", |
| ... | ... | @@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 295 | 305 | label: "environment-data:pull-requests:run-action", |
| 296 | 306 | tag: WS_METHODS.pullRequestsRunAction, |
| 297 | 307 | execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input), |
| 308 | onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), | |
| 298 | 309 | scheduler: commandScheduler, |
| 299 | 310 | concurrency: serialPerEnvironment, |
| 300 | 311 | }), |
| ... | ... | @@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 302 | 313 | label: "environment-data:pull-requests:update", |
| 303 | 314 | tag: WS_METHODS.pullRequestsUpdate, |
| 304 | 315 | execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), |
| 316 | onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), | |
| 305 | 317 | scheduler: commandScheduler, |
| 306 | 318 | concurrency: serialPerEnvironment, |
| 307 | 319 | }), |
| ... | ... | @@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 485 | 497 | label: "environment-data:pull-requests:invalidate", |
| 486 | 498 | tag: WS_METHODS.pullRequestsInvalidate, |
| 487 | 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 | 506 | scheduler: commandScheduler, |
| 489 | 507 | concurrency: serialPerEnvironment, |
| 490 | 508 | }), |
packages/contracts/src/pullRequest.ts+14| ... | ... | @@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ |
| 703 | 703 | }); |
| 704 | 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 | 721 | * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar |
| 708 | 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 | 111 | PullRequestCommentInput, |
| 112 | 112 | PullRequestCommentUpdateInput, |
| 113 | 113 | PullRequestDetail, |
| 114 | PullRequestPreview, | |
| 114 | 115 | PullRequestDiffFileContentsInput, |
| 115 | 116 | PullRequestDiffFileContentsResult, |
| 116 | 117 | PullRequestFilesViewedResult, |
| ... | ... | @@ -395,6 +396,7 @@ export const WS_METHODS = { |
| 395 | 396 | pullRequestsStack: "pullRequests.stack", |
| 396 | 397 | pullRequestsLinkedThreads: "pullRequests.linkedThreads", |
| 397 | 398 | pullRequestsDetail: "pullRequests.detail", |
| 399 | pullRequestsPreview: "pullRequests.preview", | |
| 398 | 400 | pullRequestsActivity: "pullRequests.activity", |
| 399 | 401 | pullRequestsThreadComments: "pullRequests.threadComments", |
| 400 | 402 | pullRequestsDiffFileContents: "pullRequests.diffFileContents", |
| ... | ... | @@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { |
| 737 | 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 | 748 | const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { |
| 741 | 749 | payload: PullRequestRef, |
| 742 | 750 | success: PullRequestActivity, |
| ... | ... | @@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1418 | 1426 | WsPullRequestsStackRpc, |
| 1419 | 1427 | WsPullRequestsLinkedThreadsRpc, |
| 1420 | 1428 | WsPullRequestsDetailRpc, |
| 1429 | WsPullRequestsPreviewRpc, | |
| 1421 | 1430 | WsPullRequestsActivityRpc, |
| 1422 | 1431 | WsPullRequestsThreadCommentsRpc, |
| 1423 | 1432 | WsPullRequestsDiffFileContentsRpc, |