From d2d5bba7038678eaa5fc6a885fa5ab05d0854725 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 16:50:58 -0400 Subject: [PATCH 01/25] feat(web): pull request files can be marked as viewed A review spread over an afternoon, or picked up on a second machine, started again from the top every time, so large changes were read in the browser and only small ones stayed here. The marks are the host's rather than ours because a checkbox only this app remembers is worse than none: it looks like the one GitHub shows, disagrees with it, and leaves a reviewer unsure which of the two knows what they have actually read. Signed-off-by: Yordis Prieto --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../pullRequest/GitHubPullRequestCli.test.ts | 144 +++++++++++++++++ .../src/pullRequest/GitHubPullRequestCli.ts | 118 +++++++++++++- .../pullRequest/GitHubPullRequestProvider.ts | 7 + .../src/pullRequest/PullRequestProvider.ts | 30 ++++ .../pullRequest/PullRequestService.test.ts | 81 ++++++++++ .../src/pullRequest/PullRequestService.ts | 112 ++++++++++++- .../pullRequest/gitHubPullRequestJson.test.ts | 96 ++++++++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 120 ++++++++++++++ .../sourceControl/githubGraphQlBudget.test.ts | 27 ++++ .../src/sourceControl/githubGraphQlBudget.ts | 30 +++- apps/server/src/ws.ts | 10 ++ .../pullRequest/PullRequestCodeTab.tsx | 70 ++++++++- .../pullRequest/pullRequestDiff.logic.test.ts | 34 +++- .../pullRequest/pullRequestDiff.logic.ts | 21 +++ .../pullRequestFilesViewed.logic.test.ts | 100 ++++++++++++ .../pullRequestFilesViewed.logic.ts | 82 ++++++++++ .../pullRequest/usePullRequestFilesViewed.ts | 147 ++++++++++++++++++ docs/user/source-control.md | 15 ++ .../client-runtime/src/state/pullRequests.ts | 25 +++ packages/contracts/src/pullRequest.ts | 63 ++++++++ packages/contracts/src/rpc.ts | 23 +++ 22 files changed, 1339 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts create mode 100644 apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..57b18b11f596 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, @@ -66,6 +67,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33d0d120ccce..e114abc180cb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2603,4 +2603,148 @@ layer("GitHubPullRequestCli.layer", (it) => { ]); }), ); + + it.effect("reads every page of viewed files, and says so when there are too many", () => + Effect.gen(function* () { + const page = (index: number, hasNextPage: boolean) => + Effect.succeed( + output( + JSON.stringify({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, + nodes: [ + { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, + { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, + ], + }, + }, + }, + }, + }), + ), + ); + mockedExecute + .mockReturnValueOnce(page(0, true)) + .mockReturnValueOnce(page(1, true)) + .mockReturnValueOnce(page(2, false)); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + // The first page asks from the start; each one after it carries the cursor before it. + assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after="))); + expect(callAt(1).args).toContain("after=cursor-0"); + expect(callAt(2).args).toContain("after=cursor-1"); + assert.isFalse(viewed.truncated); + expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ + ["src/file0.ts", "viewed"], + ["src/other0.ts", "unviewed"], + ["src/file1.ts", "viewed"], + ["src/other1.ts", "unviewed"], + ["src/file2.ts", "viewed"], + ["src/other2.ts", "unviewed"], + ]); + }), + ); + + it.effect("stops paging viewed files rather than following a change without end", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + pullRequest: { + files: { + pageInfo: { hasNextPage: true, endCursor: "cursor" }, + nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], + }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const viewed = yield* cli.getPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + assert.isTrue(viewed.truncated); + assert.strictEqual(viewed.files.length, 5); + }), + ); + + it.effect("clears and restores a burst of files in one request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ], + }); + + // One request to learn the pull request's node id, one for every press together. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const sent = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(sent.query).toContain("f0: markFileAsViewed"); + expect(sent.query).toContain("f1: unmarkFileAsViewed"); + expect(sent.variables).toEqual({ + pullRequestId: "PR_1", + path0: "src/a.ts", + path1: "src/b.ts", + }); + }), + ); + + it.effect("asks the host nothing when nothing was pressed", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + files: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 2084a50d0206..73b9d29005dd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -7,6 +7,7 @@ import { resolvePullRequestAuthorFilter, type PullRequestAction, type PullRequestActor, + type PullRequestFileViewed, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListState, @@ -30,10 +31,12 @@ import { ADD_REACTION_GRAPHQL_MUTATION, buildReviewSubmissionJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -53,6 +56,7 @@ import { decodeBaseComparisonJson, PULL_REQUEST_DETAIL_JSON_FIELDS, PULL_REQUEST_LIST_JSON_FIELDS, + PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, REMOVE_REACTION_GRAPHQL_MUTATION, @@ -263,6 +267,12 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; /** What the files API serves at most in one response, which is what one slice is made of. */ const DIFF_FILES_PAGE_SIZE = 100; +/** + * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL + * budget per page, against a change request nobody reviews in one sitting past the first few + * hundred files: beyond this the read stops and says it was cut short. + */ +const FILES_VIEWED_MAX_PAGES = 5; /** * Pages of review threads to follow before the conversation is reported as truncated. GitHub @@ -308,6 +318,12 @@ export interface GitHubPullRequestDiffSlice { readonly omittedFileStats?: ReadonlyArray; } +export interface GitHubPullRequestFilesViewed { + readonly files: ReadonlyArray; + /** GitHub had more files than the page budget below would read. */ + readonly truncated: boolean; +} + export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCli, { @@ -415,6 +431,30 @@ export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCliError >; + /** + * Which files of the pull request the signed-in account has cleared, and which of those have + * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL, + * and because the two answers go stale at completely different rates. + */ + readonly getPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** + * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, + * so a burst is batched with aliases into one document rather than one subprocess per press. + */ + readonly setPullRequestFilesViewed: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }) => Effect.Effect; + readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; @@ -912,14 +952,25 @@ export const make = Effect.gen(function* () { readonly host: string; readonly query: string; readonly variables: Readonly>; + /** What this write is expected to spend, for a batch that carries more than one mutation. */ + readonly estimatedCost?: number | undefined; }) => - github - .execute({ - cwd: input.cwd, - args: ["api", "graphql", "--hostname", input.host, "--input", "-"], - stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), - }) - .pipe(Effect.asVoid); + graphQlBudget + // A write is counted against the hourly budget but never held back by it, so the reserve + // that pauses reads is measured against what has really been spent rather than against + // reads alone. It cannot fail here: the budget only refuses reads. + .query(input.host, input.query, { estimatedCost: input.estimatedCost ?? 1 }) + .pipe( + Effect.orElseSucceed(() => input.query), + Effect.flatMap((query) => + github.execute({ + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ query, variables: input.variables }), + }), + ), + Effect.asVoid, + ); /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ const graphqlRead = (input: { @@ -1763,6 +1814,59 @@ export const make = Effect.gen(function* () { variables: { threadId: input.threadId, body: input.body }, }), + getPullRequestFilesViewed: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + const read = ( + after: string | null, + collected: ReadonlyArray, + pagesLeft: number, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ...(after === null + ? [] + : ([["-f", `after=${after}`]] as ReadonlyArray)), + ], + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, + }).pipe( + Effect.flatMap((page) => { + const files = [...collected, ...page.files]; + if (page.nextCursor === null) { + return Effect.succeed({ files, truncated: false }); + } + // A change nobody could read in one sitting is not worth a point of budget a page: + // the boxes on screen still work, and the count says it is partial rather than lying. + return pagesLeft <= 1 + ? Effect.succeed({ files, truncated: true }) + : read(page.nextCursor, files, pagesLeft - 1); + }), + ); + return read(null, [], FILES_VIEWED_MAX_PAGES); + }, + + setPullRequestFilesViewed: (input) => { + const mutation = buildSetFilesViewedGraphQlMutation(input.files); + if (mutation === null) return Effect.void; + return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: mutation.query, + variables: { pullRequestId, ...mutation.variables }, + estimatedCost: input.files.length, + }), + ), + ); + }, + setReviewThreadResolution: (input) => graphql({ cwd: input.cwd, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c2ed..ae057251fca9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -33,6 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["merge", "rebase"], search: true, reactions: true, + viewedFiles: true, review: { inlineComment: true, reply: true, @@ -399,6 +400,12 @@ export const make = Effect.gen(function* () { getDiffFileContents: (input) => cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + getFilesViewed: (input) => + cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), + + setFilesViewed: (input) => + cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), + listReviewerCandidates: (input) => cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 644f3552cbc5..1ecba8c04224 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -8,6 +8,7 @@ import type { PullRequestChecksState, PullRequestCheck, PullRequestComment, + PullRequestFileViewed, PullRequestCommit, PullRequestInvolvement, PullRequestLabel, @@ -201,6 +202,12 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +export interface ProviderFilesViewed { + readonly files: ReadonlyArray; + /** The host has more files than were read, so the ones missing here are not "unviewed". */ + readonly truncated: boolean; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -355,6 +362,29 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is + * true, and read apart from the patch: a host that reports this at all reports it on a clock of + * its own, moving with every press rather than with every push. + */ + readonly getFilesViewed?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is true. + * + * Takes several at once because that is how they are pressed. A provider whose host has no + * bulk form still owes one round trip for the batch rather than one per file, since the point + * of gathering them here is that the host is asked once. + */ + readonly setFilesViewed?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 84bd57dfa27b..987dba0d1bde 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3385,3 +3385,84 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("keeps the diff cached across a file being ticked off", () => + Effect.gen(function* () { + let diffReads = 0; + let viewedReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => { + diffReads += 1; + return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); + }, + getFilesViewed: () => { + viewedReads += 1; + return Effect.succeed({ + files: [{ path: "src/a.ts", state: "viewed" as const }], + truncated: false, + }); + }, + setFilesViewed: () => Effect.void, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; + + yield* service.diff(reference); + yield* service.filesViewed(reference); + yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); + yield* service.diff(reference); + yield* service.filesViewed(reference); + + // The press forgets only the reader's own ticks: a diff of any size survives it. + assert.strictEqual(diffReads, 1); + assert.strictEqual(viewedReads, 2); + }), +); + +it.effect("refuses to track viewed files on a host that does not", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + getFilesViewed: () => Effect.die("must not be called"), + setFilesViewed: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; + + const read = yield* Effect.flip(service.filesViewed(reference)); + const write = yield* Effect.flip( + service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), + ); + + assert.strictEqual(read._tag, "PullRequestOperationError"); + assert.strictEqual(write._tag, "PullRequestOperationError"); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index fc76a6501931..41b2a5bd61c5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -22,6 +22,7 @@ import { type PullRequestDiffFileContentsResult, type PullRequestDiffStat, type PullRequestDiffInput, + type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, type PullRequestListEntry, @@ -37,6 +38,7 @@ import { type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, + type PullRequestSetFilesViewedInput, type PullRequestSubmitReviewInput, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, @@ -102,6 +104,12 @@ const DIFF_CACHE_TTL = Duration.seconds(60); const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ const LIST_STATS_CACHE_TTL = Duration.seconds(60); +/** + * Short, and with no stale window behind it: this is the reader's own bookkeeping, and the + * press that changes it is the same press the page is already showing optimistically. Held at + * all only so opening a change request on two devices costs one read. + */ +const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); /** * How long a cache's last success may still be served while a fresh read runs behind it. * Bounded by how the page actually revalidates: clients re-read on mount and once a minute @@ -119,6 +127,7 @@ const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; +const FILES_VIEWED_CACHE_CAPACITY = 128; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -144,6 +153,12 @@ export class PullRequestService extends Context.Service< readonly diffFileContents: ( input: PullRequestDiffFileContentsInput, ) => Effect.Effect; + readonly filesViewed: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly setFilesViewed: ( + input: PullRequestSetFilesViewedInput, + ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -450,6 +465,12 @@ function withRateLimitBackoff( ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), + ...(api.getFilesViewed === undefined + ? {} + : { getFilesViewed: wrap("getFilesViewed", api.getFilesViewed) }), + ...(api.setFilesViewed === undefined + ? {} + : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -1297,6 +1318,51 @@ export const make = Effect.gen(function* () { }), ); + const filesViewedUncached = (input: PullRequestRef) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getFilesViewed; + return project.api.capabilities.viewedFiles === true && read + ? read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "filesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + ); + + const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const write = project.api.setFilesViewed; + return project.api.capabilities.viewedFiles === true && write + ? write({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + files: input.files, + }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the + // change request, and dropping a 300-file diff on every checkbox is the whole cost of + // the feature. Only this reader's own bookkeeping is forgotten. + Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(input))), + ); + const runAction: PullRequestService["Service"]["runAction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -1872,14 +1938,20 @@ export const make = Effect.gen(function* () { const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; - const bumpRefEpoch = (ref: PullRequestRef) => { + const bumpEpoch = (epochs: Map, ref: PullRequestRef) => { const scope = refScope(ref); - if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { - const oldest = refEpochs.keys().next().value; - if (oldest !== undefined) refEpochs.delete(oldest); + if (!epochs.has(scope) && epochs.size >= REF_EPOCH_CAPACITY) { + const oldest = epochs.keys().next().value; + if (oldest !== undefined) epochs.delete(oldest); } - refEpochs.set(scope, ++epochCounter); + epochs.set(scope, ++epochCounter); }; + const bumpRefEpoch = (ref: PullRequestRef) => bumpEpoch(refEpochs, ref); + // Its own scope, so a press forgets the reader's ticks and nothing else. The read's key + // carries both epochs, which is what makes an ordinary refresh re-ask for these too. + const filesViewedEpochs = new Map(); + const filesViewedEpoch = (ref: PullRequestRef) => filesViewedEpochs.get(refScope(ref)) ?? 0; + const bumpFilesViewedEpoch = (ref: PullRequestRef) => bumpEpoch(filesViewedEpochs, ref); /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ const filtersOfKey = ( @@ -2060,6 +2132,34 @@ export const make = Effect.gen(function* () { return staleDiff(key, Cache.get(diffCache, key)); }; + const filesViewedCache = yield* Cache.makeWith( + (key: string) => { + const [, , projectId, repository, number] = JSON.parse(key) as [ + number, + number, + string, + string, + number, + ]; + return filesViewedUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: FILES_VIEWED_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), + }, + ); + const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => + Cache.get( + filesViewedCache, + JSON.stringify([ + refEpoch(input), + filesViewedEpoch(input), + input.projectId, + input.repository, + input.number, + ]), + ); + const listStatsCache = yield* Cache.makeWith( (key: string) => { const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number]>]; @@ -2130,6 +2230,8 @@ export const make = Effect.gen(function* () { threadComments, diff, diffFileContents, + filesViewed, + setFilesViewed, runAction: invalidatedByMutation(runAction), update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac3000a0..f20f20d5a265 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeBaseComparisonJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -1361,3 +1363,97 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("decodePullRequestFilesViewedJson", () => { + const page = ( + nodes: ReadonlyArray, + pageInfo: { hasNextPage: boolean; endCursor: string | null }, + ) => + JSON.stringify({ + data: { repository: { pullRequest: { files: { pageInfo, nodes } } } }, + }); + + it("reads each file's state and where the next page carries on", () => { + const decoded = decodePullRequestFilesViewedJson( + page( + [ + { path: "src/a.ts", viewerViewedState: "VIEWED" }, + { path: "src/b.ts", viewerViewedState: "UNVIEWED" }, + { path: "src/c.ts", viewerViewedState: "DISMISSED" }, + ], + { hasNextPage: true, endCursor: "cursor-2" }, + ), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "unviewed" }, + { path: "src/c.ts", state: "dismissed" }, + ], + nextCursor: "cursor-2", + }); + }); + + it("treats a state it has never heard of as unread rather than failing the page", () => { + const decoded = decodePullRequestFilesViewedJson( + page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], { + hasNextPage: false, + endCursor: null, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ + files: [{ path: "src/a.ts", state: "unviewed" }], + nextCursor: null, + }); + }); + + it("answers empty for a pull request the host has nothing to say about", () => { + const decoded = decodePullRequestFilesViewedJson( + JSON.stringify({ data: { repository: { pullRequest: null } } }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect(decoded.success).toEqual({ files: [], nextCursor: null }); + }); +}); + +describe("buildSetFilesViewedGraphQlMutation", () => { + it("asks for nothing when nothing was pressed", () => { + expect(buildSetFilesViewedGraphQlMutation([])).toBeNull(); + }); + + it("clears and restores in one document, each file under its own alias", () => { + const mutation = buildSetFilesViewedGraphQlMutation([ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: false }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).toContain( + "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)", + ); + expect(mutation.query).toContain( + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })", + ); + expect(mutation.query).toContain( + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })", + ); + expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" }); + }); + + it("keeps a path out of the document, so one cannot be read as part of it", () => { + const mutation = buildSetFilesViewedGraphQlMutation([ + { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, + ]); + expect(mutation).not.toBeNull(); + if (mutation === null) return; + expect(mutation.query).not.toContain("evil"); + expect(mutation.variables.path0).toBe( + '") { __typename } evil: markFileAsViewed(input: { path: "x', + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea111b3..773b3aa6700b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -9,6 +9,7 @@ import type { PullRequestChecksState, PullRequestComment, PullRequestCommit, + PullRequestFileViewedState, PullRequestLabel, PullRequestMergeCapabilities, PullRequestOmittedFileStat, @@ -2239,3 +2240,122 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** + * Which files of a pull request the signed-in account has cleared. + * + * GraphQL only — the REST files endpoint the patch is read from carries no viewed state at all, + * so this is a second read rather than a wider version of the first. One page of a hundred files + * costs a single point of the hourly budget, which is why it can ride the diff's own refresh + * without being noticed. + */ +export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { path viewerViewedState } + } + } + } +}`; + +const RawPullRequestFilesViewedSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + files: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.NullOr(Schema.String), + }), + nodes: Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.String, + // Decoded as a plain string and narrowed below: a GitHub release that adds + // a fourth state must not fail the whole page. + viewerViewedState: Schema.String, + }), + ), + ), + ), + }), + }), + ), + }), + ), + }), +}); + +const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); + +export interface GitHubPullRequestFilesViewedPage { + readonly files: ReadonlyArray<{ + readonly path: string; + readonly state: PullRequestFileViewedState; + }>; + /** Where the next page carries on, or null once the host has no more to give. */ + readonly nextCursor: string | null; +} + +/** Anything this host does not name is treated as unread, which is the state that asks for least. */ +function toFileViewedState(raw: string): PullRequestFileViewedState { + switch (raw.trim().toUpperCase()) { + case "VIEWED": + return "viewed"; + case "DISMISSED": + return "dismissed"; + default: + return "unviewed"; + } +} + +export function decodePullRequestFilesViewedJson( + raw: string, +): Result.Result { + const decoded = decodePullRequestFilesViewed(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const files = decoded.success.data.repository?.pullRequest?.files; + if (files === undefined) return Result.succeed({ files: [], nextCursor: null }); + return Result.succeed({ + files: (files.nodes ?? []).flatMap((node) => + node === null || node.path.length === 0 + ? [] + : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }], + ), + nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null, + }); +} + +/** + * One document that clears and restores as many files as the reader ticked, rather than one + * request each. + * + * GitHub has no bulk form of either mutation — `markFileAsViewed` and `unmarkFileAsViewed` take a + * single path — so the batching is done with aliases. Top-level mutation fields run in the order + * they are written, so the last word about a path is the one that sticks, and the whole burst + * costs one HTTP round trip and one subprocess instead of one of each per press. + * + * Paths travel as variables rather than inside the document: they are the host's own strings, but + * a path is data and a document is not, and building one out of the other is how injection starts. + */ +export function buildSetFilesViewedGraphQlMutation( + files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, +): { readonly query: string; readonly variables: Readonly> } | null { + if (files.length === 0) return null; + const parameters = files.map((_, index) => `$path${index}: String!`).join(", "); + const fields = files + .map( + (file, index) => + ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`, + ) + .join("\n"); + return { + query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`, + variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])), + }; +} diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index a166bf0dbbaf..b85371c810e2 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -186,4 +186,31 @@ describe("GitHub GraphQL budget", () => { expect(yield* budget.query("github.com", mutation)).toBe(mutation); }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + + it.effect("charges a write for the batch it carries, since it cannot report its own cost", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + // Twenty points above the reserve, which is exactly what the mutation below spends. + yield* budget.observe("github.com", rateLimit(520)); + + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 20, + }); + + const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + + it.effect("lets a write through even with nothing left, rather than holding a press back", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(0)); + + const mutation = "mutation { f0: markFileAsViewed { id } }"; + expect(yield* budget.query("github.com", mutation, { estimatedCost: 40 })).toBe(mutation); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); }); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 9c43de8e0586..8745d691bc84 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -23,7 +23,14 @@ export class GitHubGraphQlBudget extends Context.Service< readonly query: ( host: string, document: string, - options?: { readonly allowReserve: boolean }, + options?: { + readonly allowReserve?: boolean | undefined; + /** + * What a write is expected to spend, for the debit above. Ignored for a read, which + * reports its own cost. Defaults to one point, which is a mutation's floor. + */ + readonly estimatedCost?: number | undefined; + }, ) => Effect.Effect; readonly observe: (host: string, raw: string) => Effect.Effect; } @@ -82,8 +89,27 @@ export const make = Effect.gen(function* () { const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( function* (host, document, options) { - if (!isReadOperation(document)) return document; const now = yield* Clock.currentTimeMillis; + // A write spends the same hourly points a read does, and `rateLimit` is a field of Query + // alone — so a mutation cannot report its own cost and is debited from the held snapshot + // instead. Never paused, only counted: a mutation is somebody pressing something, and + // holding it back to protect a read nobody has asked for yet is the wrong trade. The + // estimate only has to last until the next read, whose answer replaces the snapshot with + // the host's own number. + if (!isReadOperation(document)) { + yield* Ref.update(snapshots, (current) => { + const key = hostKey(host); + const snapshot = current.get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return current; + const next = new Map(current); + next.set(key, { + ...snapshot, + remaining: Math.max(0, snapshot.remaining - Math.max(1, options?.estimatedCost ?? 1)), + }); + return next; + }); + return document; + } const retryAt = yield* Ref.modify(snapshots, (current) => { const key = hostKey(host); const snapshot = current.get(key); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c5b7e50a8704..350b8bf6f7c9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1693,6 +1693,16 @@ const makeWsRpcLayer = ( pullRequests.diffFileContents(input), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsFilesViewed]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsFilesViewed, pullRequests.filesViewed(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsSetFilesViewed]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetFilesViewed, + pullRequests.setFilesViewed(input), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsRunAction]: (input) => observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b0e00d57cc61..fa9e5ed97026 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,6 +58,7 @@ import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, @@ -73,9 +74,11 @@ import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, + toggleFileDiffFoldForViewed, type DiffFoldOverride, } from "./pullRequestDiff.logic"; import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; +import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; import { nextPendingReviewCommentId, pullRequestReviewKey, @@ -396,6 +399,17 @@ export function PullRequestCodeTab({ ), [parsedSlices], ); + const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]); + // Offered under a commit scope as well as from the whole change, because reading a change one + // commit at a time is what the scope is for. The tick itself stays the host's: it is kept + // against the change request, so clearing a file here clears it everywhere. + const filesViewed = usePullRequestFilesViewed({ + environmentId, + reference, + enabled: detail.capabilities.viewedFiles === true, + paths: filePaths, + }); + const { setViewed } = filesViewed; const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. @@ -587,6 +601,19 @@ export function PullRequestCodeTab({ [], ); + // The tick and the fold are one gesture: clearing a file puts it away, un-clearing brings it + // back. Folding is still held as the reader's difference from the toolbar's default rather + // than derived from what has been ticked, so folding everything ticks nothing off. + const setFileViewed = useCallback( + (fileKey: string, path: string, viewed: boolean) => { + setViewed(path, viewed); + setToggledFiles((current) => + toggleFileDiffFoldForViewed(fileKey, viewed, foldOverride, current), + ); + }, + [foldOverride, setViewed], + ); + const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader @@ -722,19 +749,51 @@ export function PullRequestCodeTab({ additions += hunk.additionLines; deletions += hunk.deletionLines; } + const path = resolveFileDiffPath(item.fileDiff); if (additions === 0 && deletions === 0) { - const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff)); + const withheld = omittedFileStats.get(path); if (withheld) ({ additions, deletions } = withheld); } - return ( + const stat = ( ); + if (!filesViewed.enabled) return stat; + const viewed = filesViewed.isViewed(path); + const stale = filesViewed.isStale(path); + return ( + + {stat} + {/* The header itself folds the file, so the tick has to keep its press to itself. */} + + + ); }, - [omittedFileStats], + [filesViewed, omittedFileStats, setFileViewed], ); const diffViewOptions = useMemo( @@ -1058,6 +1117,11 @@ export function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {filesViewed.enabled && files.length > 0 ? ( + + {filesViewed.viewedCount} / {files.length} viewed + + ) : null} {withheldContent ? ( }> diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff1b5..5a5ae8149097 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -1,7 +1,11 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic"; +import { + isFileDiffCollapsed, + isLineInFileDiff, + toggleFileDiffFoldForViewed, +} from "./pullRequestDiff.logic"; /** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */ function fileWithHunks( @@ -79,3 +83,31 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); }); }); + +describe("toggleFileDiffFoldForViewed", () => { + it("puts a file away when it is ticked off", () => { + // Files start folded, so one the reader had opened is the case that has somewhere to go. + const opened = new Set(["a.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", true, null, opened)]).toEqual([]); + }); + + it("brings a file back when the tick is taken off", () => { + expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set())]).toEqual(["a.ts"]); + }); + + it("leaves the fold alone when it already says what the tick does", () => { + const folded = new Set(); + expect(toggleFileDiffFoldForViewed("a.ts", true, null, folded)).toBe(folded); + }); + + it("moves against whatever the toolbar last asked for", () => { + // Everything is open, so ticking a file off has to fold that one against the default. + expect([...toggleFileDiffFoldForViewed("a.ts", true, "expanded", new Set())]).toEqual(["a.ts"]); + expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); + }); + + it("touches only the file that was ticked", () => { + const toggled = new Set(["a.ts", "b.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", true, null, toggled)]).toEqual(["b.ts"]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe9c2..8a6061c4e5c6 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -42,3 +42,24 @@ export function isFileDiffCollapsed( const foldedByDefault = foldOverride !== "expanded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } + +/** + * The reader's fold choices after a file was ticked off, or put back. + * + * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if + * the reader had pressed the chevron themselves — which keeps folding a difference from what the + * toolbar last asked, and so keeps "collapse all" from ticking anything off. + */ +export function toggleFileDiffFoldForViewed( + fileKey: string, + viewed: boolean, + foldOverride: DiffFoldOverride, + toggledFileKeys: ReadonlySet, +): ReadonlySet { + if (isFileDiffCollapsed(fileKey, foldOverride, toggledFileKeys) === viewed) + return toggledFileKeys; + const next = new Set(toggledFileKeys); + if (next.has(fileKey)) next.delete(fileKey); + else next.add(fileKey); + return next; +} diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts new file mode 100644 index 000000000000..90c3d71f9b04 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + countViewedFiles, + isFileViewed, + isStaleViewedState, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, +} from "./pullRequestFilesViewed.logic"; + +const NO_OVERLAY: FileViewedOverlay = new Map(); +const NOTHING_PENDING: ReadonlySet = new Set(); + +const states = toFileViewedStates({ + files: [ + { path: "a.ts", state: "viewed" }, + { path: "b.ts", state: "unviewed" }, + { path: "c.ts", state: "dismissed" }, + ], + truncated: false, +}); + +describe("isFileViewed", () => { + it("follows the host for a file the reader has not pressed", () => { + expect(isFileViewed("a.ts", states, NO_OVERLAY)).toBe(true); + expect(isFileViewed("b.ts", states, NO_OVERLAY)).toBe(false); + }); + + it("reads a file pushed to since it was cleared as unread", () => { + expect(isFileViewed("c.ts", states, NO_OVERLAY)).toBe(false); + expect(isStaleViewedState(states?.get("c.ts"))).toBe(true); + expect(isStaleViewedState(states?.get("a.ts"))).toBe(false); + }); + + it("shows the press ahead of the host's answer", () => { + expect(isFileViewed("b.ts", states, new Map([["b.ts", true]]))).toBe(true); + expect(isFileViewed("a.ts", states, new Map([["a.ts", false]]))).toBe(false); + }); + + it("answers a file the host has said nothing about, before its answer arrives", () => { + expect(isFileViewed("z.ts", null, NO_OVERLAY)).toBe(false); + expect(isFileViewed("z.ts", null, new Map([["z.ts", true]]))).toBe(true); + }); +}); + +describe("countViewedFiles", () => { + it("counts only the files on screen, presses included", () => { + expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, NO_OVERLAY)).toBe(1); + expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, new Map([["b.ts", true]]))).toBe(2); + // A file the host knows about but the diff has not paged in yet is not counted. + expect(countViewedFiles(["b.ts"], states, NO_OVERLAY)).toBe(0); + }); +}); + +describe("settleFileViewedOverlay", () => { + it("drops a press the host has caught up on", () => { + const settled = settleFileViewedOverlay(new Map([["a.ts", true]]), states, NOTHING_PENDING); + expect(settled.size).toBe(0); + }); + + it("keeps a press the host still disagrees with", () => { + const overlay = new Map([["b.ts", true]]); + expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING)).toBe(overlay); + }); + + it("keeps a press the host cannot have heard yet", () => { + // An answer already on its way when the file was un-ticked would otherwise put the tick back. + const overlay = new Map([["a.ts", false]]); + const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"])); + expect(settled.get("a.ts")).toBe(false); + }); + + it("settles a file pushed to since it was cleared against un-ticking it", () => { + const settled = settleFileViewedOverlay(new Map([["c.ts", false]]), states, NOTHING_PENDING); + expect(settled.size).toBe(0); + }); + + it("holds everything until the host has answered at all", () => { + const overlay = new Map([["a.ts", true]]); + expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING)).toBe(overlay); + }); +}); + +describe("toFileViewedBatch", () => { + it("carries both directions in one batch", () => { + expect( + toFileViewedBatch( + new Map([ + ["a.ts", false], + ["b.ts", true], + ]), + ), + ).toEqual([ + { path: "a.ts", viewed: false }, + { path: "b.ts", viewed: true }, + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts new file mode 100644 index 000000000000..2bc011297a53 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -0,0 +1,82 @@ +import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts"; + +/** What the host last said about each file, by path. Absent means the host said nothing. */ +export type FileViewedStates = ReadonlyMap; + +/** Presses the host has not confirmed yet, by path. */ +export type FileViewedOverlay = ReadonlyMap; + +export function toFileViewedStates( + result: PullRequestFilesViewedResult | null, +): FileViewedStates | null { + if (result === null) return null; + return new Map(result.files.map((file) => [file.path, file.state])); +} + +/** + * Whether a file counts as seen. + * + * `dismissed` is the host saying it has been pushed to since the reader cleared it, which reads + * as unseen — the point of the tick is that the code behind it has been looked at, and it is not + * the same code any more. + */ +export function isViewedState(state: PullRequestFileViewedState | undefined): boolean { + return state === "viewed"; +} + +/** Whether the file was cleared and has since moved, which the header says out loud. */ +export function isStaleViewedState(state: PullRequestFileViewedState | undefined): boolean { + return state === "dismissed"; +} + +/** The press the reader made if it has not landed, and the host's answer otherwise. */ +export function isFileViewed( + path: string, + states: FileViewedStates | null, + overlay: FileViewedOverlay, +): boolean { + const pressed = overlay.get(path); + return pressed ?? isViewedState(states?.get(path)); +} + +export function countViewedFiles( + paths: ReadonlyArray, + states: FileViewedStates | null, + overlay: FileViewedOverlay, +): number { + return paths.reduce( + (total, path) => (isFileViewed(path, states, overlay) ? total + 1 : total), + 0, + ); +} + +/** + * The overlay with everything the host has caught up on removed. + * + * A press is held locally until the host's own answer agrees with it, rather than cleared when + * the request succeeds: the read that follows a write is a separate round trip, and dropping the + * press in between would flash the checkbox back for as long as that took. + * + * `unsettled` are the paths whose press the host cannot have heard yet, which an answer that was + * already on its way when they were pressed must not be allowed to overrule. + */ +export function settleFileViewedOverlay( + overlay: FileViewedOverlay, + states: FileViewedStates | null, + unsettled: ReadonlySet, +): FileViewedOverlay { + if (states === null || overlay.size === 0) return overlay; + const next = new Map(overlay); + for (const [path, pressed] of overlay) { + if (unsettled.has(path)) continue; + if (isViewedState(states.get(path)) === pressed) next.delete(path); + } + return next.size === overlay.size ? overlay : next; +} + +/** The presses in an overlay as the batch the host is told about. */ +export function toFileViewedBatch( + overlay: FileViewedOverlay, +): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> { + return [...overlay].map(([path, viewed]) => ({ path, viewed })); +} diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts new file mode 100644 index 000000000000..32d6934a57ec --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -0,0 +1,147 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { toastManager } from "../ui/toast"; +import { + countViewedFiles, + isFileViewed, + isStaleViewedState, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, +} from "./pullRequestFilesViewed.logic"; + +/** + * How long presses gather before the host is told. Long enough that ticking down a file list + * costs one request rather than one per file, short enough that a reader who ticks one file and + * closes the tab has already been recorded. + */ +const FLUSH_DELAY_MS = 400; + +const NO_OVERLAY: FileViewedOverlay = new Map(); +const NO_PATHS: ReadonlySet = new Set(); + +export interface PullRequestFilesViewedView { + /** Whether the host tracks this at all, which is what hides the whole control. */ + readonly enabled: boolean; + readonly isViewed: (path: string) => boolean; + /** The host says this file has been pushed to since it was cleared. */ + readonly isStale: (path: string) => boolean; + readonly setViewed: (path: string, viewed: boolean) => void; + /** How many of the files on screen are ticked off. */ + readonly viewedCount: number; +} + +/** + * Which files this reader has already cleared, as the host records it. + * + * The state lives on the host rather than here so a review carried on from another machine, or + * from the host's own web UI, picks up where it was left. Presses show immediately and are held + * over the host's answer until it agrees with them, so the checkbox never waits on a round trip. + */ +export function usePullRequestFilesViewed(options: { + readonly environmentId: EnvironmentId; + readonly reference: PullRequestRef; + readonly enabled: boolean; + /** The paths on screen, which is what the counter counts. */ + readonly paths: ReadonlyArray; +}): PullRequestFilesViewedView { + const { environmentId, reference, enabled, paths } = options; + const query = useEnvironmentQuery( + enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null, + ); + const refresh = query.refresh; + const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const [overlay, setOverlay] = useState(NO_OVERLAY); + const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); + + // Presses waiting for the next flush, and the ones a request is already carrying. Both are + // refs rather than state: nothing on screen reads them, and the flush must see the latest. + const queued = useRef>(new Map()); + const inFlight = useRef>(NO_PATHS); + const flushTimer = useRef | null>(null); + + const referenceKey = `${reference.projectId} ${reference.repository} ${reference.number}`; + // Everything held here is about one change request, so switching away drops it rather than + // letting a press meant for one land on another. + useEffect(() => { + queued.current = new Map(); + inFlight.current = NO_PATHS; + setOverlay(NO_OVERLAY); + }, [referenceKey]); + + useEffect(() => { + setOverlay((current) => + settleFileViewedOverlay( + current, + states, + new Set([...queued.current.keys(), ...inFlight.current]), + ), + ); + }, [states]); + + const flush = useCallback(() => { + flushTimer.current = null; + const batch = toFileViewedBatch(queued.current); + if (batch.length === 0) return; + queued.current = new Map(); + const sent = new Set(batch.map((file) => file.path)); + inFlight.current = sent; + void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { + inFlight.current = NO_PATHS; + if (result._tag === "Failure") { + // The host never heard these, so the ticks go back to whatever it last said. + setOverlay((current) => { + const next = new Map(current); + for (const path of sent) next.delete(path); + return next; + }); + toastManager.add({ type: "error", title: "Could not update viewed files" }); + return; + } + refresh(); + }); + }, [environmentId, reference, refresh, setFilesViewed]); + + // Read through a ref rather than closed over: `setViewed` is handed to every file header the + // viewer draws, and a new identity per render would rebuild all of them. + const flushRef = useRef(flush); + flushRef.current = flush; + + // A tab closed mid-gather still records what was pressed. + useEffect( + () => () => { + if (flushTimer.current === null) return; + clearTimeout(flushTimer.current); + flushRef.current(); + }, + [], + ); + + const setViewed = useCallback((path: string, viewed: boolean) => { + setOverlay((current) => new Map(current).set(path, viewed)); + queued.current.set(path, viewed); + if (flushTimer.current !== null) clearTimeout(flushTimer.current); + flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); + }, []); + + const isViewed = useCallback( + (path: string) => isFileViewed(path, states, overlay), + [overlay, states], + ); + const isStale = useCallback( + (path: string) => !overlay.has(path) && isStaleViewedState(states?.get(path)), + [overlay, states], + ); + const viewedCount = useMemo( + () => countViewedFiles(paths, states, overlay), + [overlay, paths, states], + ); + + return { enabled, isViewed, isStale, setViewed, viewedCount }; +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..1bf50e0cd1a1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -53,6 +53,21 @@ T3 Code works with the platforms your team already uses: - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its comments stay read-only here, as they already were +**Keep your place in a long review** + +- Tick a file off in the **Code** tab once you have read it. The file collapses, and the toolbar + keeps a running count of how many files you have cleared +- Untick it to open the file back up +- Your ticks are stored with the pull request itself, so a review you start on one machine picks up + where you left it on the next, and in your browser too +- If a file is pushed to after you cleared it, it comes back marked **Changed** so you know to look + again +- GitHub only. GitLab, Bitbucket, and Azure DevOps do not keep this, so the checkbox is not shown + there +- Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one + commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear + there is cleared everywhere + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d4830fa197d4..33d9b528a699 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -106,6 +106,31 @@ export function createPullRequestEnvironmentAtoms( ]), }, }), + /** + * Which files this reader has already cleared, apart from the diff: the answer moves with + * every checkbox rather than with every push, and a patch of a few hundred files must not + * be re-fetched to learn that one box was ticked. + */ + filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:files-viewed", + tag: WS_METHODS.pullRequestsFilesViewed, + staleTimeMs: 15_000, + }), + /** + * One request per batch of presses, and one in flight per change request: the host applies + * these in order, and a reader ticking down a file list faster than the round trip would + * otherwise race their own presses. + */ + setFilesViewed: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:set-files-viewed", + tag: WS_METHODS.pullRequestsSetFilesViewed, + scheduler: commandScheduler, + concurrency: { + mode: "serial", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.projectId, input.repository, input.number]), + }, + }), runAction: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:run-action", tag: WS_METHODS.pullRequestsRunAction, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index a49868937844..86a8927d4461 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -384,6 +384,16 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this field was. */ reactions: Schema.optional(Schema.Boolean), + /** + * A file can be marked as read by the person reading it, and the mark taken back. Optional for + * the same reason as `reactions`: a server that says nothing about it has none, which is what + * every server before this field was. + * + * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is + * forgotten the moment the tab closes is worse than no checkbox — it looks like the one beside + * it and keeps none of its promises. + */ + viewedFiles: Schema.optional(Schema.Boolean), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /** @@ -800,6 +810,59 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +/** + * Where one file of a change request stands with the person reading it. + * + * `dismissed` is the state that earns this its own read: the file was cleared, and has since been + * pushed to. It is not `viewed` — the reader has not seen what is there now — and it is not + * `unviewed` either, because saying so would lose the one thing worth telling them, which is that + * this file and not the other forty is the one that moved. + */ +export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]); +export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; + +export const PullRequestFileViewed = Schema.Struct({ + path: TrimmedNonEmptyString, + state: PullRequestFileViewedState, +}); +export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; + +/** + * Which files of a change request the reader has cleared, read apart from the diff itself. + * + * Its own read rather than a field on the patch, for the same reason the listing's line counts + * are their own: the two move on entirely different clocks. A patch changes when somebody pushes, + * and is cached by the minute; this changes on every press of the checkbox. Carrying it on the + * diff would mean either forgetting a three-hundred-file patch each time a box is ticked, or + * showing a reader their own last press as stale. + */ +export const PullRequestFilesViewedResult = Schema.Struct({ + /** Only the files the host reported a state for. A file missing from this list is unviewed. */ + files: Schema.Array(PullRequestFileViewed), + /** + * The host had more files than were read. The checkbox still works on everything on screen; + * the count beside it is the one thing that cannot be trusted to be whole, and says so. + */ + truncated: Schema.Boolean, +}); +export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type; + +/** + * Files to clear, or to put back. Several at once because a reader working down a diff ticks + * boxes far faster than a host answers: the surface gathers a burst into one request rather than + * opening a subprocess per press. + */ +export const PullRequestSetFilesViewedInput = Schema.Struct({ + ...PullRequestRef.fields, + files: Schema.Array( + Schema.Struct({ + path: TrimmedNonEmptyString, + viewed: Schema.Boolean, + }), + ), +}); +export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type; + export const PullRequestActionInput = Schema.Struct({ ...PullRequestRef.fields, action: PullRequestAction, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..af1b4ba2a1ac 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -75,6 +75,7 @@ import { PullRequestDetail, PullRequestDiffFileContentsInput, PullRequestDiffFileContentsResult, + PullRequestFilesViewedResult, PullRequestInvalidateInput, PullRequestListInput, PullRequestListResult, @@ -85,6 +86,7 @@ import { PullRequestRef, PullRequestReviewerCandidateList, PullRequestReviewerRequestInput, + PullRequestSetFilesViewedInput, PullRequestSubmitReviewInput, PullRequestThreadCommentsInput, PullRequestThreadCommentsResult, @@ -285,6 +287,8 @@ export const WS_METHODS = { pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", pullRequestsDiffFileContents: "pullRequests.diffFileContents", + pullRequestsFilesViewed: "pullRequests.filesViewed", + pullRequestsSetFilesViewed: "pullRequests.setFilesViewed", pullRequestsRunAction: "pullRequests.runAction", pullRequestsUpdate: "pullRequests.update", pullRequestsComment: "pullRequests.comment", @@ -517,6 +521,23 @@ export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequest error: PullRequestRpcError, }); +/** + * Which files the reader has already cleared. Its own call rather than a field on the diff: the + * patch is cached by the minute and this moves on every press of a checkbox, so sharing a read + * would make one of the two wrong. + */ +export const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, { + payload: PullRequestRef, + success: PullRequestFilesViewedResult, + error: PullRequestRpcError, +}); + +export const WsPullRequestsSetFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsSetFilesViewed, { + payload: PullRequestSetFilesViewedInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, @@ -1012,6 +1033,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc, + WsPullRequestsFilesViewedRpc, + WsPullRequestsSetFilesViewedRpc, WsPullRequestsRunActionRpc, WsPullRequestsUpdateRpc, WsPullRequestsCommentRpc, From cf5ac4c80da63b8f73630d80aefc0ecf7aac3430 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:48 -0400 Subject: [PATCH 02/25] fix(server): an overpriced write guess no longer pauses reads until the window resets Signed-off-by: Yordis Prieto --- .../sourceControl/githubGraphQlBudget.test.ts | 36 +++++++++++++++++++ .../src/sourceControl/githubGraphQlBudget.ts | 24 ++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index b85371c810e2..da8377b9aeb8 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -203,6 +203,42 @@ describe("GitHub GraphQL budget", () => { }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + it.effect("takes the host's own number over a write's guess, however high the guess was", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(4_000)); + // A batch charged far more than it really spent would otherwise hold reads until the reset. + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 3_900, + }); + yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + + yield* budget.observe("github.com", rateLimit(3_990)); + + expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( + "rateLimit", + ); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + + it.effect("still ignores an out-of-order answer once the guess has been settled", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(600)); + yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { + estimatedCost: 50, + }); + // The host's own number settles the guess, and the answer behind it is stale again. + yield* budget.observe("github.com", rateLimit(513)); + yield* budget.observe("github.com", rateLimit(600)); + + const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); + expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + it.effect("lets a write through even with nothing left, rather than holding a press back", () => Effect.gen(function* () { yield* TestClock.setTime(BEFORE_RESET); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 8745d691bc84..daa52bfb0d62 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -15,6 +15,12 @@ interface GraphQlBudgetSnapshot { readonly limit: number; readonly remaining: number; readonly resetAtMs: number; + /** + * Points taken off `remaining` for writes the host has not answered for yet. A mutation cannot + * ask what it cost, so this is a guess, and while a guess is standing the host's own number is + * allowed to raise `remaining` again instead of being read as an out-of-order answer. + */ + readonly estimatedSpend: number; } export class GitHubGraphQlBudget extends Context.Service< @@ -66,7 +72,9 @@ function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { return null; } const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; + return Number.isFinite(resetAtMs) + ? { cost, limit, remaining, resetAtMs, estimatedSpend: 0 } + : null; } catch { return null; } @@ -91,7 +99,7 @@ export const make = Effect.gen(function* () { function* (host, document, options) { const now = yield* Clock.currentTimeMillis; // A write spends the same hourly points a read does, and `rateLimit` is a field of Query - // alone — so a mutation cannot report its own cost and is debited from the held snapshot + // alone, so a mutation cannot report its own cost and is debited from the held snapshot // instead. Never paused, only counted: a mutation is somebody pressing something, and // holding it back to protect a read nobody has asked for yet is the wrong trade. The // estimate only has to last until the next read, whose answer replaces the snapshot with @@ -101,10 +109,12 @@ export const make = Effect.gen(function* () { const key = hostKey(host); const snapshot = current.get(key); if (snapshot === undefined || snapshot.resetAtMs <= now) return current; + const spend = Math.max(1, options?.estimatedCost ?? 1); const next = new Map(current); next.set(key, { ...snapshot, - remaining: Math.max(0, snapshot.remaining - Math.max(1, options?.estimatedCost ?? 1)), + remaining: Math.max(0, snapshot.remaining - spend), + estimatedSpend: snapshot.estimatedSpend + spend, }); return next; }); @@ -148,10 +158,16 @@ export const make = Effect.gen(function* () { const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. + // + // Unless a write's guess is standing: that number was never the host's, and an estimate + // pitched too high would otherwise pause every read until the window reset, with the one + // answer that could correct it thrown away for looking stale. if ( previous !== undefined && (snapshot.resetAtMs < previous.resetAtMs || - (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining)) + (snapshot.resetAtMs === previous.resetAtMs && + previous.estimatedSpend === 0 && + snapshot.remaining >= previous.remaining)) ) { return current; } From e62d906b920142210c31218c5d6b011c53ce8cf1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:49 -0400 Subject: [PATCH 03/25] fix(web): a failed press no longer takes back a tick the reader made since Signed-off-by: Yordis Prieto --- .../pullRequestFilesViewed.logic.test.ts | 32 ++++++++ .../pullRequestFilesViewed.logic.ts | 22 +++++- .../pullRequest/usePullRequestFilesViewed.ts | 77 +++++++++++-------- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index 90c3d71f9b04..78dd8d1298ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -4,6 +4,7 @@ import { countViewedFiles, isFileViewed, isStaleViewedState, + revertFileViewedOverlay, settleFileViewedOverlay, toFileViewedBatch, toFileViewedStates, @@ -98,3 +99,34 @@ describe("toFileViewedBatch", () => { ]); }); }); + +describe("revertFileViewedOverlay", () => { + const batch = [ + { path: "a.ts", viewed: true }, + { path: "b.ts", viewed: false }, + ]; + + it("puts the checkbox back to the host's answer for everything the request carried", () => { + const overlay = new Map([ + ["a.ts", true], + ["b.ts", false], + ]); + expect(revertFileViewedOverlay(overlay, batch, new Set()).size).toBe(0); + }); + + it("leaves a press the reader made after the request went out", () => { + // The second press is queued behind a request of its own, so the first one failing says + // nothing about it. + const overlay = new Map([ + ["a.ts", false], + ["b.ts", false], + ]); + const reverted = revertFileViewedOverlay(overlay, batch, new Set(["a.ts"])); + expect([...reverted]).toEqual([["a.ts", false]]); + }); + + it("leaves a path the request never carried", () => { + const overlay = new Map([["c.ts", true]]); + expect(revertFileViewedOverlay(overlay, batch, new Set())).toBe(overlay); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 2bc011297a53..04c03ba424fa 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -17,7 +17,7 @@ export function toFileViewedStates( * Whether a file counts as seen. * * `dismissed` is the host saying it has been pushed to since the reader cleared it, which reads - * as unseen — the point of the tick is that the code behind it has been looked at, and it is not + * as unseen: the point of the tick is that the code behind it has been looked at, and it is not * the same code any more. */ export function isViewedState(state: PullRequestFileViewedState | undefined): boolean { @@ -74,6 +74,26 @@ export function settleFileViewedOverlay( return next.size === overlay.size ? overlay : next; } +/** + * The overlay with a failed request's presses taken back. + * + * Only the presses that request carried, and only where the checkbox still shows them: a path + * the reader has pressed again since is waiting on a request of its own, and putting that box + * back to the host's answer would take a press out from under the reader's hand. + */ +export function revertFileViewedOverlay( + overlay: FileViewedOverlay, + batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, + superseded: ReadonlySet, +): FileViewedOverlay { + const next = new Map(overlay); + for (const { path, viewed } of batch) { + if (superseded.has(path)) continue; + if (next.get(path) === viewed) next.delete(path); + } + return next.size === overlay.size ? overlay : next; +} + /** The presses in an overlay as the batch the host is told about. */ export function toFileViewedBatch( overlay: FileViewedOverlay, diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 32d6934a57ec..b84028ee7352 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -10,6 +10,7 @@ import { countViewedFiles, isFileViewed, isStaleViewedState, + revertFileViewedOverlay, settleFileViewedOverlay, toFileViewedBatch, toFileViewedStates, @@ -24,7 +25,6 @@ import { const FLUSH_DELAY_MS = 400; const NO_OVERLAY: FileViewedOverlay = new Map(); -const NO_PATHS: ReadonlySet = new Set(); export interface PullRequestFilesViewedView { /** Whether the host tracks this at all, which is what hides the whole control. */ @@ -35,6 +35,8 @@ export interface PullRequestFilesViewedView { readonly setViewed: (path: string, viewed: boolean) => void; /** How many of the files on screen are ticked off. */ readonly viewedCount: number; + /** The host had more files than the read covered, so the count above may be short. */ + readonly truncated: boolean; } /** @@ -57,30 +59,28 @@ export function usePullRequestFilesViewed(options: { ); const refresh = query.refresh; const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const truncated = query.data?.truncated === true; const [overlay, setOverlay] = useState(NO_OVERLAY); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); // Presses waiting for the next flush, and the ones a request is already carrying. Both are // refs rather than state: nothing on screen reads them, and the flush must see the latest. const queued = useRef>(new Map()); - const inFlight = useRef>(NO_PATHS); + const inFlight = useRef>(new Map()); const flushTimer = useRef | null>(null); - const referenceKey = `${reference.projectId} ${reference.repository} ${reference.number}`; - // Everything held here is about one change request, so switching away drops it rather than - // letting a press meant for one land on another. - useEffect(() => { - queued.current = new Map(); - inFlight.current = NO_PATHS; - setOverlay(NO_OVERLAY); - }, [referenceKey]); + // Everything held here belongs to one change request on one environment. The environment is + // part of that: two of them can hand out the same project id, and a press made against one + // must never be answered for by the other. + const scopeKey = `${environmentId} ${reference.projectId} ${reference.repository} ${reference.number}`; + const scope = useRef(scopeKey); useEffect(() => { setOverlay((current) => settleFileViewedOverlay( current, states, - new Set([...queued.current.keys(), ...inFlight.current]), + new Set([...queued.current.keys(), ...inFlight.current.keys()]), ), ); }, [states]); @@ -90,17 +90,22 @@ export function usePullRequestFilesViewed(options: { const batch = toFileViewedBatch(queued.current); if (batch.length === 0) return; queued.current = new Map(); - const sent = new Set(batch.map((file) => file.path)); - inFlight.current = sent; + const sentFrom = scope.current; + for (const file of batch) inFlight.current.set(file.path, file.viewed); void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { - inFlight.current = NO_PATHS; + // Only what this request carried, and only where a later press has not taken the path over. + for (const file of batch) { + if (inFlight.current.get(file.path) === file.viewed) inFlight.current.delete(file.path); + } + // The reader has moved to another change request, or another environment, and what is on + // screen now has nothing to do with this answer. + if (scope.current !== sentFrom) return; if (result._tag === "Failure") { - // The host never heard these, so the ticks go back to whatever it last said. - setOverlay((current) => { - const next = new Map(current); - for (const path of sent) next.delete(path); - return next; - }); + // The host never heard these, so the ticks go back to whatever it last said, except on + // a path pressed again since, where the newer press is still waiting on its own request. + setOverlay((current) => + revertFileViewedOverlay(current, batch, new Set(queued.current.keys())), + ); toastManager.add({ type: "error", title: "Could not update viewed files" }); return; } @@ -113,15 +118,22 @@ export function usePullRequestFilesViewed(options: { const flushRef = useRef(flush); flushRef.current = flush; - // A tab closed mid-gather still records what was pressed. - useEffect( - () => () => { - if (flushTimer.current === null) return; - clearTimeout(flushTimer.current); - flushRef.current(); - }, - [], - ); + // Leaving a change request, the environment it lives on, or the page itself records what was + // pressed and then drops the rest. The flush kept here is the one bound to the scope being + // left, which is what sends those last presses where they were meant to go. + useEffect(() => { + const flushScope = flushRef.current; + scope.current = scopeKey; + return () => { + if (flushTimer.current !== null) { + clearTimeout(flushTimer.current); + flushScope(); + } + queued.current = new Map(); + inFlight.current = new Map(); + setOverlay(NO_OVERLAY); + }; + }, [scopeKey]); const setViewed = useCallback((path: string, viewed: boolean) => { setOverlay((current) => new Map(current).set(path, viewed)); @@ -143,5 +155,10 @@ export function usePullRequestFilesViewed(options: { [overlay, paths, states], ); - return { enabled, isViewed, isStale, setViewed, viewedCount }; + // One identity per change of what it says: the viewer keys every file it draws off this, and a + // fresh object each render would redraw the whole diff. + return useMemo( + () => ({ enabled, isViewed, isStale, setViewed, viewedCount, truncated }), + [enabled, isStale, isViewed, setViewed, truncated, viewedCount], + ); } From 19a679c710c9ee4638b00047eaef177d6897a12a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:49 -0400 Subject: [PATCH 04/25] fix(web): a viewed tick redraws its file, and a partial count says that it is partial Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index fa9e5ed97026..772333d94350 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -485,6 +485,12 @@ export function PullRequestCodeTab({ } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + // The header carries the reader's own tick, and the viewer redraws a file only when its + // version moves. Ticking a file that is already folded changes no fold, so without this + // the box on screen would keep saying the opposite of what the count says. + const viewedMark = filesViewed.enabled + ? `${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` + : ""; const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), @@ -500,7 +506,7 @@ export function PullRequestCodeTab({ // The viewer re-renders an item only when its version changes, so everything the // annotations show has to be part of it. version: fnv1a32( - `${collapsed ? "1" : "0"}:${annotations + `${collapsed ? "1" : "0"}:${viewedMark}:${annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending @@ -534,6 +540,7 @@ export function PullRequestCodeTab({ detail.reviewThreads, draft, files, + filesViewed, foldOverride, pendingComments, placedThreadIds, @@ -774,7 +781,6 @@ export function PullRequestCodeTab({ > setFileViewed(item.id, path, next === true)} /> {stale ? ( @@ -1118,8 +1124,22 @@ export function PullRequestCodeTab({ {nextCursor === null ? "" : "+"} {filesViewed.enabled && files.length > 0 ? ( - + {filesViewed.viewedCount} / {files.length} viewed + {filesViewed.truncated ? ( + + }> + + + + This change has more files than the host will report ticks for in one read, so + the count is short and some boxes below start empty. + + + ) : null} ) : null} {withheldContent ? ( From 447fd191c007ec85345588b713522107ee5954db Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:09:50 -0400 Subject: [PATCH 05/25] style: plainer punctuation in the viewed files comments Signed-off-by: Yordis Prieto --- apps/server/src/pullRequest/gitHubPullRequestJson.ts | 12 ++++++------ .../components/pullRequest/pullRequestDiff.logic.ts | 2 +- packages/contracts/src/pullRequest.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 773b3aa6700b..c77514b5f6d6 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2244,10 +2244,10 @@ export function decodePullRequestFilesJson( /** * Which files of a pull request the signed-in account has cleared. * - * GraphQL only — the REST files endpoint the patch is read from carries no viewed state at all, - * so this is a second read rather than a wider version of the first. One page of a hundred files - * costs a single point of the hourly budget, which is why it can ride the diff's own refresh - * without being noticed. + * GraphQL only, since the REST files endpoint the patch is read from carries no viewed state at + * all, so this is a second read rather than a wider version of the first. One page of a hundred + * files costs a single point of the hourly budget, which is why it can ride the diff's own + * refresh without being noticed. */ export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { @@ -2335,8 +2335,8 @@ export function decodePullRequestFilesViewedJson( * One document that clears and restores as many files as the reader ticked, rather than one * request each. * - * GitHub has no bulk form of either mutation — `markFileAsViewed` and `unmarkFileAsViewed` take a - * single path — so the batching is done with aliases. Top-level mutation fields run in the order + * GitHub has no bulk form of either mutation, and `markFileAsViewed` and `unmarkFileAsViewed` + * take a single path, so the batching is done with aliases. Top-level mutation fields run in the order * they are written, so the last word about a path is the one that sticks, and the whole burst * costs one HTTP round trip and one subprocess instead of one of each per press. * diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index 8a6061c4e5c6..75b680be1790 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -47,7 +47,7 @@ export function isFileDiffCollapsed( * The reader's fold choices after a file was ticked off, or put back. * * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if - * the reader had pressed the chevron themselves — which keeps folding a difference from what the + * the reader had pressed the chevron themselves, which keeps folding a difference from what the * toolbar last asked, and so keeps "collapse all" from ticking anything off. */ export function toggleFileDiffFoldForViewed( diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 86a8927d4461..598c7caf18ad 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -390,7 +390,7 @@ export const PullRequestCapabilities = Schema.Struct({ * every server before this field was. * * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is - * forgotten the moment the tab closes is worse than no checkbox — it looks like the one beside + * forgotten the moment the tab closes is worse than no checkbox: it looks like the one beside * it and keeps none of its promises. */ viewedFiles: Schema.optional(Schema.Boolean), @@ -814,7 +814,7 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten * Where one file of a change request stands with the person reading it. * * `dismissed` is the state that earns this its own read: the file was cleared, and has since been - * pushed to. It is not `viewed` — the reader has not seen what is there now — and it is not + * pushed to. It is not `viewed`, since the reader has not seen what is there now, and it is not * `unviewed` either, because saying so would lose the one thing worth telling them, which is that * this file and not the other forty is the one that moved. */ From d085d082b29b36578cd8d86b551041fff3a929da Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:22:06 -0400 Subject: [PATCH 06/25] fix(web): pressing the word beside the box no longer folds the file the wrong way Signed-off-by: Yordis Prieto --- .../sourceControl/githubGraphQlBudget.test.ts | 63 ------------------- .../src/sourceControl/githubGraphQlBudget.ts | 50 ++------------- .../pullRequest/PullRequestCodeTab.tsx | 9 ++- 3 files changed, 12 insertions(+), 110 deletions(-) diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index da8377b9aeb8..a166bf0dbbaf 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -186,67 +186,4 @@ describe("GitHub GraphQL budget", () => { expect(yield* budget.query("github.com", mutation)).toBe(mutation); }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); - - it.effect("charges a write for the batch it carries, since it cannot report its own cost", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - // Twenty points above the reserve, which is exactly what the mutation below spends. - yield* budget.observe("github.com", rateLimit(520)); - - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 20, - }); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("takes the host's own number over a write's guess, however high the guess was", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(4_000)); - // A batch charged far more than it really spent would otherwise hold reads until the reset. - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 3_900, - }); - yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - yield* budget.observe("github.com", rateLimit(3_990)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("still ignores an out-of-order answer once the guess has been settled", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(600)); - yield* budget.query("github.com", "mutation { f0: markFileAsViewed { id } }", { - estimatedCost: 50, - }); - // The host's own number settles the guess, and the answer behind it is stale again. - yield* budget.observe("github.com", rateLimit(513)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ _tag: "SourceControlRateLimitPausedError" }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("lets a write through even with nothing left, rather than holding a press back", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const mutation = "mutation { f0: markFileAsViewed { id } }"; - expect(yield* budget.query("github.com", mutation, { estimatedCost: 40 })).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); }); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index daa52bfb0d62..9c43de8e0586 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -15,12 +15,6 @@ interface GraphQlBudgetSnapshot { readonly limit: number; readonly remaining: number; readonly resetAtMs: number; - /** - * Points taken off `remaining` for writes the host has not answered for yet. A mutation cannot - * ask what it cost, so this is a guess, and while a guess is standing the host's own number is - * allowed to raise `remaining` again instead of being read as an out-of-order answer. - */ - readonly estimatedSpend: number; } export class GitHubGraphQlBudget extends Context.Service< @@ -29,14 +23,7 @@ export class GitHubGraphQlBudget extends Context.Service< readonly query: ( host: string, document: string, - options?: { - readonly allowReserve?: boolean | undefined; - /** - * What a write is expected to spend, for the debit above. Ignored for a read, which - * reports its own cost. Defaults to one point, which is a mutation's floor. - */ - readonly estimatedCost?: number | undefined; - }, + options?: { readonly allowReserve: boolean }, ) => Effect.Effect; readonly observe: (host: string, raw: string) => Effect.Effect; } @@ -72,9 +59,7 @@ function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { return null; } const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) - ? { cost, limit, remaining, resetAtMs, estimatedSpend: 0 } - : null; + return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; } catch { return null; } @@ -97,29 +82,8 @@ export const make = Effect.gen(function* () { const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( function* (host, document, options) { + if (!isReadOperation(document)) return document; const now = yield* Clock.currentTimeMillis; - // A write spends the same hourly points a read does, and `rateLimit` is a field of Query - // alone, so a mutation cannot report its own cost and is debited from the held snapshot - // instead. Never paused, only counted: a mutation is somebody pressing something, and - // holding it back to protect a read nobody has asked for yet is the wrong trade. The - // estimate only has to last until the next read, whose answer replaces the snapshot with - // the host's own number. - if (!isReadOperation(document)) { - yield* Ref.update(snapshots, (current) => { - const key = hostKey(host); - const snapshot = current.get(key); - if (snapshot === undefined || snapshot.resetAtMs <= now) return current; - const spend = Math.max(1, options?.estimatedCost ?? 1); - const next = new Map(current); - next.set(key, { - ...snapshot, - remaining: Math.max(0, snapshot.remaining - spend), - estimatedSpend: snapshot.estimatedSpend + spend, - }); - return next; - }); - return document; - } const retryAt = yield* Ref.modify(snapshots, (current) => { const key = hostKey(host); const snapshot = current.get(key); @@ -158,16 +122,10 @@ export const make = Effect.gen(function* () { const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. - // - // Unless a write's guess is standing: that number was never the host's, and an estimate - // pitched too high would otherwise pause every read until the window reset, with the one - // answer that could correct it thrown away for looking stale. if ( previous !== undefined && (snapshot.resetAtMs < previous.resetAtMs || - (snapshot.resetAtMs === previous.resetAtMs && - previous.estimatedSpend === 0 && - snapshot.remaining >= previous.remaining)) + (snapshot.resetAtMs === previous.resetAtMs && snapshot.remaining >= previous.remaining)) ) { return current; } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 772333d94350..15f61bfdd401 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -774,8 +774,11 @@ export function PullRequestCodeTab({ return ( {stat} - {/* The header itself folds the file, so the tick has to keep its press to itself. */} + {/* The header itself folds the file, so the tick has to keep its press to itself. The + attribute is what the header's capture listener looks for: pressing the word next to + the box is pressing the box, and the fold that follows is the tick's to make. */} (input: { @@ -1861,7 +1850,6 @@ export const make = Effect.gen(function* () { host: input.host, query: mutation.query, variables: { pullRequestId, ...mutation.variables }, - estimatedCost: input.files.length, }), ), ); From dad689d0a63a9db6b54bc5e407227430266e0916 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:22:10 -0400 Subject: [PATCH 08/25] fix(web): a failed request no longer answers for a press a later one carries Signed-off-by: Yordis Prieto --- .../pullRequestFilesViewed.logic.test.ts | 20 +++++++---- .../pullRequestFilesViewed.logic.ts | 12 ++++--- .../pullRequest/usePullRequestFilesViewed.ts | 35 +++++++++++-------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index 78dd8d1298ee..88d3f5708c09 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -105,28 +105,36 @@ describe("revertFileViewedOverlay", () => { { path: "a.ts", viewed: true }, { path: "b.ts", viewed: false }, ]; + const both = new Set(["a.ts", "b.ts"]); - it("puts the checkbox back to the host's answer for everything the request carried", () => { + it("puts the checkbox back to the host's answer for everything the request answers for", () => { const overlay = new Map([ ["a.ts", true], ["b.ts", false], ]); - expect(revertFileViewedOverlay(overlay, batch, new Set()).size).toBe(0); + expect(revertFileViewedOverlay(overlay, batch, both).size).toBe(0); }); it("leaves a press the reader made after the request went out", () => { - // The second press is queued behind a request of its own, so the first one failing says - // nothing about it. + // The second press is waiting on a flush of its own, so the first one failing says nothing + // about it. const overlay = new Map([ ["a.ts", false], ["b.ts", false], ]); - const reverted = revertFileViewedOverlay(overlay, batch, new Set(["a.ts"])); + const reverted = revertFileViewedOverlay(overlay, batch, new Set(["b.ts"])); expect([...reverted]).toEqual([["a.ts", false]]); }); + it("leaves a path a later request took over, even pressed the same way", () => { + // Both requests carry `a.ts` as viewed, so the value cannot tell them apart. The later one + // owns the path now and is the one that answers for it. + const overlay = new Map([["a.ts", true]]); + expect(revertFileViewedOverlay(overlay, batch, new Set(["b.ts"]))).toBe(overlay); + }); + it("leaves a path the request never carried", () => { const overlay = new Map([["c.ts", true]]); - expect(revertFileViewedOverlay(overlay, batch, new Set())).toBe(overlay); + expect(revertFileViewedOverlay(overlay, batch, both)).toBe(overlay); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 04c03ba424fa..14d3d462ec7b 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -77,18 +77,20 @@ export function settleFileViewedOverlay( /** * The overlay with a failed request's presses taken back. * - * Only the presses that request carried, and only where the checkbox still shows them: a path - * the reader has pressed again since is waiting on a request of its own, and putting that box - * back to the host's answer would take a press out from under the reader's hand. + * `owned` are the paths that request still answers for, which is what keeps a failure from + * reaching past its own presses: a path pressed again since belongs to a later request or to the + * next flush, and putting that box back to the host's answer would take a press out from under + * the reader's hand. Even among those, a press is only taken back where the checkbox still shows + * it. */ export function revertFileViewedOverlay( overlay: FileViewedOverlay, batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, - superseded: ReadonlySet, + owned: ReadonlySet, ): FileViewedOverlay { const next = new Map(overlay); for (const { path, viewed } of batch) { - if (superseded.has(path)) continue; + if (!owned.has(path)) continue; if (next.get(path) === viewed) next.delete(path); } return next.size === overlay.size ? overlay : next; diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index b84028ee7352..fc1ecb671881 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -63,10 +63,14 @@ export function usePullRequestFilesViewed(options: { const [overlay, setOverlay] = useState(NO_OVERLAY); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); - // Presses waiting for the next flush, and the ones a request is already carrying. Both are - // refs rather than state: nothing on screen reads them, and the flush must see the latest. + // Presses waiting for the next flush, and, for every path a request is already carrying, which + // request that is. Requests overlap and run in the order they were made, so a path pressed + // again while an earlier one is still out belongs to the later request from that moment on, and + // the earlier one stops answering for it. Both are refs rather than state: nothing on screen + // reads them, and the flush must see the latest. const queued = useRef>(new Map()); - const inFlight = useRef>(new Map()); + const sentBy = useRef>(new Map()); + const requests = useRef(0); const flushTimer = useRef | null>(null); // Everything held here belongs to one change request on one environment. The environment is @@ -80,7 +84,7 @@ export function usePullRequestFilesViewed(options: { settleFileViewedOverlay( current, states, - new Set([...queued.current.keys(), ...inFlight.current.keys()]), + new Set([...queued.current.keys(), ...sentBy.current.keys()]), ), ); }, [states]); @@ -91,21 +95,22 @@ export function usePullRequestFilesViewed(options: { if (batch.length === 0) return; queued.current = new Map(); const sentFrom = scope.current; - for (const file of batch) inFlight.current.set(file.path, file.viewed); + const request = ++requests.current; + for (const file of batch) sentBy.current.set(file.path, request); void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { - // Only what this request carried, and only where a later press has not taken the path over. - for (const file of batch) { - if (inFlight.current.get(file.path) === file.viewed) inFlight.current.delete(file.path); - } + const mine = batch + .map((file) => file.path) + .filter((path) => sentBy.current.get(path) === request); + for (const path of mine) sentBy.current.delete(path); // The reader has moved to another change request, or another environment, and what is on // screen now has nothing to do with this answer. if (scope.current !== sentFrom) return; if (result._tag === "Failure") { - // The host never heard these, so the ticks go back to whatever it last said, except on - // a path pressed again since, where the newer press is still waiting on its own request. - setOverlay((current) => - revertFileViewedOverlay(current, batch, new Set(queued.current.keys())), - ); + // The host never heard these, so the ticks go back to whatever it last said. Only the + // paths this request still answers for: one pressed again since is waiting on a request + // of its own, or on the next flush, and that press is the one on screen. + const owned = new Set(mine.filter((path) => !queued.current.has(path))); + setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); toastManager.add({ type: "error", title: "Could not update viewed files" }); return; } @@ -130,7 +135,7 @@ export function usePullRequestFilesViewed(options: { flushScope(); } queued.current = new Map(); - inFlight.current = new Map(); + sentBy.current = new Map(); setOverlay(NO_OVERLAY); }; }, [scopeKey]); From 962864338f56630528864b03a374de807515b77d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:38:40 -0400 Subject: [PATCH 09/25] fix(web): a viewed tick no longer rebuilds every header on screen A press moved the whole viewed view, and every file header on screen was memoized on it, so one tick cost a rebuild of all of them. The same mark also has to say whether the control is offered at all, or a capability arriving after the first paint leaves the headers without a box. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 15f61bfdd401..36326e6e2f78 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -489,7 +489,7 @@ export function PullRequestCodeTab({ // version moves. Ticking a file that is already folded changes no fold, so without this // the box on screen would keep saying the opposite of what the count says. const viewedMark = filesViewed.enabled - ? `${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` + ? `e${filesViewed.isViewed(path) ? "v" : ""}${filesViewed.isStale(path) ? "s" : ""}` : ""; const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ @@ -747,6 +747,15 @@ export function PullRequestCodeTab({ [toggleFile], ); + // Read through refs rather than closed over. The viewer memoizes each visible file's header + // portal on the callback below, so a fresh identity on every tick, and on every refresh of the + // host's answer, would rebuild every header on screen. Each item's version carries the same + // marks, which is what redraws the one file whose tick moved. + const filesViewedRef = useRef(filesViewed); + filesViewedRef.current = filesViewed; + const setFileViewedRef = useRef(setFileViewed); + setFileViewedRef.current = setFileViewed; + const renderHeaderMetadata = useCallback( (item: CodeViewItem) => { if (item.type !== "diff") return null; @@ -768,9 +777,10 @@ export function PullRequestCodeTab({ className="font-mono text-[11px]" /> ); - if (!filesViewed.enabled) return stat; - const viewed = filesViewed.isViewed(path); - const stale = filesViewed.isStale(path); + const viewedFiles = filesViewedRef.current; + if (!viewedFiles.enabled) return stat; + const viewed = viewedFiles.isViewed(path); + const stale = viewedFiles.isStale(path); return ( {stat} @@ -784,7 +794,7 @@ export function PullRequestCodeTab({ > setFileViewed(item.id, path, next === true)} + onCheckedChange={(next) => setFileViewedRef.current(item.id, path, next === true)} /> {stale ? ( @@ -802,7 +812,7 @@ export function PullRequestCodeTab({ ); }, - [filesViewed, omittedFileStats, setFileViewed], + [omittedFileStats], ); const diffViewOptions = useMemo( From 6b44e5156469157681c5c0b91b112f5ad12ed3f7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 17:38:47 -0400 Subject: [PATCH 10/25] fix(web): the viewed box says what it is for out loud It borrowed its name from the label beside it, and that label turns into "Changed" once the file has been pushed to, leaving a reader who cannot see it with no idea what the box does. Signed-off-by: Yordis Prieto --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 36326e6e2f78..815f00a844fe 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -792,7 +792,10 @@ export function PullRequestCodeTab({ className="flex cursor-pointer select-none items-center gap-1.5 text-[11px] text-muted-foreground" onClick={(event) => event.stopPropagation()} > + {/* Named here rather than by the label, whose text turns into "Changed" once the + file has been pushed to. */} setFileViewedRef.current(item.id, path, next === true)} /> From aa7a828204f2703c189cde5c9c3eba0a2b925ab0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:02:24 -0400 Subject: [PATCH 11/25] fix(web): a refreshed review re-asks for the ticks The button exists for a reader who can see that what they are looking at is behind, so leaving one part of the page on the last read defeats the point of pressing it. A push since that read is exactly when the mark beside a ticked file stops being true. Signed-off-by: Yordis Prieto --- .../pullRequest/PullRequestCodeTab.tsx | 20 +++++++++------- .../pullRequest/usePullRequestFilesViewed.ts | 23 +++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 815f00a844fe..23978183e825 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -320,13 +320,6 @@ export function PullRequestCodeTab({ input: { ...reference, ...(commit === null ? {} : { commit }) }, }), ); - const appliedRefreshToken = useRef(refreshToken); - useEffect(() => { - if (appliedRefreshToken.current === refreshToken) return; - appliedRefreshToken.current = refreshToken; - setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); - refreshFirstDiffPage(); - }, [refreshToken, scopeKey, refreshFirstDiffPage]); const reviewKey = referenceKey; const pendingComments = usePendingReviewComments(reference); const addComment = usePullRequestReviewStore((store) => store.addComment); @@ -409,7 +402,18 @@ export function PullRequestCodeTab({ enabled: detail.capabilities.viewedFiles === true, paths: filePaths, }); - const { setViewed } = filesViewed; + const { setViewed, refresh: refreshFilesViewed } = filesViewed; + // The button goes around the host's cache, so everything the tab reads from it starts over: + // the diff from its first page, and with it the ticks, which a push since the last read can + // have marked as standing against an older version of the file. + const appliedRefreshToken = useRef(refreshToken); + useEffect(() => { + if (appliedRefreshToken.current === refreshToken) return; + appliedRefreshToken.current = refreshToken; + setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); + refreshFirstDiffPage(); + refreshFilesViewed(); + }, [refreshToken, scopeKey, refreshFirstDiffPage, refreshFilesViewed]); const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index fc1ecb671881..06e7b9dfd2af 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -37,6 +37,11 @@ export interface PullRequestFilesViewedView { readonly viewedCount: number; /** The host had more files than the read covered, so the count above may be short. */ readonly truncated: boolean; + /** + * Re-ask the host. The page's refresh button goes around the host's cache, and the ticks and + * the marks beside them are part of what the reader asked to be shown again. + */ + readonly refresh: () => void; } /** @@ -140,6 +145,12 @@ export function usePullRequestFilesViewed(options: { }; }, [scopeKey]); + // Held through a ref for the same reason `setViewed` is: it goes into the view object below, + // which every file header keys off, so it has to keep one identity for the tab's life. + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + const refreshFromHost = useCallback(() => refreshRef.current(), []); + const setViewed = useCallback((path: string, viewed: boolean) => { setOverlay((current) => new Map(current).set(path, viewed)); queued.current.set(path, viewed); @@ -163,7 +174,15 @@ export function usePullRequestFilesViewed(options: { // One identity per change of what it says: the viewer keys every file it draws off this, and a // fresh object each render would redraw the whole diff. return useMemo( - () => ({ enabled, isViewed, isStale, setViewed, viewedCount, truncated }), - [enabled, isStale, isViewed, setViewed, truncated, viewedCount], + () => ({ + enabled, + isViewed, + isStale, + setViewed, + viewedCount, + truncated, + refresh: refreshFromHost, + }), + [enabled, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], ); } From 5f080475793566c93ba87312092e10ad7c996c5e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:21:29 -0400 Subject: [PATCH 12/25] fix(web): a superseded write no longer reports a failure An error the reader cannot act on, about a press they have already replaced, reads as their current tick having been lost when it has not. Signed-off-by: Yordis Prieto --- .../components/pullRequest/usePullRequestFilesViewed.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 06e7b9dfd2af..f8b727e9b66f 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -116,7 +116,12 @@ export function usePullRequestFilesViewed(options: { // of its own, or on the next flush, and that press is the one on screen. const owned = new Set(mine.filter((path) => !queued.current.has(path))); setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); - toastManager.add({ type: "error", title: "Could not update viewed files" }); + // Nothing here was still this request's to answer for, so nothing on screen went back. + // A later press carries every one of these paths now, and it is the one that gets to say + // whether the reader's tick reached the host. + if (owned.size > 0) { + toastManager.add({ type: "error", title: "Could not update viewed files" }); + } return; } refresh(); From 3c279bf305afc0a5cb83791d866c2a0ebb0314ba Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 27 Aug 2026 20:41:43 -0400 Subject: [PATCH 13/25] fix(web): a dropped connection no longer reports a rejected write Signed-off-by: Yordis Prieto --- .../pullRequest/usePullRequestFilesViewed.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index f8b727e9b66f..573b823b1170 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -1,3 +1,4 @@ +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -66,7 +67,9 @@ export function usePullRequestFilesViewed(options: { const states = useMemo(() => toFileViewedStates(query.data), [query.data]); const truncated = query.data?.truncated === true; const [overlay, setOverlay] = useState(NO_OVERLAY); - const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed); + const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, { + reportFailure: false, + }); // Presses waiting for the next flush, and, for every path a request is already carrying, which // request that is. Requests overlap and run in the order they were made, so a path pressed @@ -116,10 +119,11 @@ export function usePullRequestFilesViewed(options: { // of its own, or on the next flush, and that press is the one on screen. const owned = new Set(mine.filter((path) => !queued.current.has(path))); setOverlay((current) => revertFileViewedOverlay(current, batch, owned)); - // Nothing here was still this request's to answer for, so nothing on screen went back. - // A later press carries every one of these paths now, and it is the one that gets to say - // whether the reader's tick reached the host. - if (owned.size > 0) { + // Two silences here. Nothing was still this request's to answer for, so nothing on + // screen went back and a later press is the one that gets to speak for these paths. Or + // the connection went away mid-flight, which the reader is already being told about and + // which the host never refused. + if (owned.size > 0 && !isAtomCommandInterrupted(result)) { toastManager.add({ type: "error", title: "Could not update viewed files" }); } return; From f5c7f62c7c7364036d12e093a4cf593cfd72180b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 09:26:51 -0400 Subject: [PATCH 14/25] feat(server): GitLab reviewers can keep their place in a long merge request GitLab keeps viewed files in one browser's local storage, so there was nothing to read or write there. The marks are this environment's instead, and the surface says whose they are rather than implying gitlab.com will show them. Signed-off-by: Yordis Prieto --- apps/server/src/persistence/Errors.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/044_PullRequestFilesViewed.ts | 24 +++ .../src/persistence/PullRequestFilesViewed.ts | 156 ++++++++++++++ .../pullRequest/GitHubPullRequestProvider.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 125 ++++++++++++ .../src/pullRequest/GitLabPullRequestCli.ts | 79 +++++++ .../pullRequest/GitLabPullRequestProvider.ts | 14 ++ .../src/pullRequest/PullRequestProvider.ts | 36 +++- .../pullRequest/PullRequestService.test.ts | 192 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 185 ++++++++++++++--- .../gitLabMergeRequestJson.test.ts | 67 ++++++ .../src/pullRequest/gitLabMergeRequestJson.ts | 74 +++++++ apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 3 + .../pullRequest/PullRequestCodeTab.tsx | 23 ++- .../pullRequest/usePullRequestFilesViewed.ts | 14 +- docs/user/source-control.md | 8 +- packages/contracts/src/pullRequest.ts | 29 ++- 19 files changed, 985 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts create mode 100644 apps/server/src/persistence/PullRequestFilesViewed.ts diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d63..1772d0a23a6c 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -134,5 +134,6 @@ export type OrchestrationCommandReceiptRepositoryError = export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8abbe87fce3e..1cf4ae548dd4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; +import Migration0044 from "./Migrations/044_PullRequestFilesViewed.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +112,7 @@ export const migrationEntries = [ [41, "AuthSessionClientConnection", Migration0041], [42, "ProjectionThreadLinkedPullRequest", Migration0042], [43, "ProjectionThreadsUnsettledAt", Migration0043], + [44, "PullRequestFilesViewed", Migration0044], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts new file mode 100644 index 000000000000..53183ccebbd7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_PullRequestFilesViewed.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per file a reader has cleared on a host that keeps no record of its own. `revision` + // is what the file was when it was cleared, so a push that changes it is reported as changed + // rather than silently left ticked. Unticking deletes the row: absent is the resting state, and + // a table of "not viewed" rows would grow with every diff anybody scrolled past. + yield* sql` + CREATE TABLE IF NOT EXISTS pull_request_files_viewed ( + provider TEXT NOT NULL, + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + viewer TEXT NOT NULL, + path TEXT NOT NULL, + revision TEXT NOT NULL, + viewed_at TEXT NOT NULL, + PRIMARY KEY (provider, host, repository, number, viewer, path) + ) WITHOUT ROWID + `; +}); diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts new file mode 100644 index 000000000000..fada7d5271be --- /dev/null +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -0,0 +1,156 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { SourceControlProviderKind } from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PullRequestFilesViewedRepositoryError, +} from "./Errors.ts"; + +/** + * Which change request, on which host, for which reader. + * + * The host is part of it because a repository path is not unique across installs: the same + * `group/project` exists on gitlab.com and on a self-managed instance, and a mark made against one + * must not turn up on the other. The reader is part of it for the same reason the host's own + * record is per-account: signing in as somebody else must not inherit their ticks. A host that + * will not say who the reader is leaves it empty, which is one reader rather than none. + */ +export const PullRequestFilesViewedScope = Schema.Struct({ + provider: SourceControlProviderKind, + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + viewer: Schema.String, +}); +export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type; + +/** A file this reader cleared, and what it was when they cleared it. */ +export const PullRequestFileViewedMark = Schema.Struct({ + path: Schema.String, + /** + * The host's own name for that version of the file, opaque here. Empty where the host had none + * to give, which is its own answer rather than a missing one: a file with no version at the head + * is one the change request deletes, and it stays deleted. + */ + revision: Schema.String, +}); +export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; + +export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope { + readonly files: ReadonlyArray; + /** When the presses landed, as an ISO instant. */ + readonly viewedAt: string; +} + +/** + * The marks this environment keeps for hosts that keep none of their own. + * + * Only cleared files are rows. Unticking deletes rather than writing a "not viewed" row, so the + * table holds what a reader has done and not what they have merely scrolled past. + */ +export class PullRequestFilesViewedRepository extends Context.Service< + PullRequestFilesViewedRepository, + { + readonly list: ( + input: PullRequestFilesViewedScope, + ) => Effect.Effect< + ReadonlyArray, + PullRequestFilesViewedRepositoryError + >; + readonly set: ( + input: SetPullRequestFilesViewedInput, + ) => Effect.Effect; + } +>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {} + +function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): PullRequestFilesViewedRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listRows = SqlSchema.findAll({ + Request: PullRequestFilesViewedScope, + Result: PullRequestFileViewedMark, + execute: ({ provider, host, repository, number, viewer }) => + sql` + SELECT + path AS "path", + revision AS "revision" + FROM pull_request_files_viewed + WHERE provider = ${provider} + AND host = ${host} + AND repository = ${repository} + AND number = ${number} + AND viewer = ${viewer} + `, + }); + + return PullRequestFilesViewedRepository.of({ + list: (input) => + listRows(input).pipe( + Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")), + ), + + // One statement per file rather than one for the batch: the batch is what a reader ticked in + // the last few hundred milliseconds, so it is a handful of rows on a local database, and a + // mixed batch of clears and un-clears has no single statement anyway. + set: (input) => + Effect.forEach( + input.files, + (file) => + file.viewed + ? sql` + INSERT INTO pull_request_files_viewed ( + provider, + host, + repository, + number, + viewer, + path, + revision, + viewed_at + ) + VALUES ( + ${input.provider}, + ${input.host}, + ${input.repository}, + ${input.number}, + ${input.viewer}, + ${file.path}, + ${file.revision}, + ${input.viewedAt} + ) + ON CONFLICT (provider, host, repository, number, viewer, path) + DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at + ` + : sql` + DELETE FROM pull_request_files_viewed + WHERE provider = ${input.provider} + AND host = ${input.host} + AND repository = ${input.repository} + AND number = ${input.number} + AND viewer = ${input.viewer} + AND path = ${file.path} + `, + { discard: true }, + ).pipe( + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }), + ), + ), + }); +}); + +export const layer = Layer.effect(PullRequestFilesViewedRepository, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index ae057251fca9..932beca8d33c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -33,7 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["merge", "rebase"], search: true, reactions: true, - viewedFiles: true, + viewedFiles: "host", review: { inlineComment: true, reply: true, diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 014d91a02740..64c1e6af5aa3 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1402,4 +1402,129 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(callAt(0).stdin).toBe('{"body":"true"}'); }), ); + it.effect("reads blob ids for the marked paths at the merge request's head", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/gone.ts"], + }); + + // A path the head does not have is absent rather than empty, which is the answer for a + // file the merge request deletes. + expect([...revisions]).toEqual([["src/a.ts", "aaa"]]); + // The head the reader is looking at, not whatever the source branch has moved on to. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); + expect(body).toMatchObject({ + variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, + }); + }), + ); + + it.effect("asks GitLab nothing when no file is marked", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: [], + }); + + expect([...revisions]).toEqual([]); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + + it.effect("splits the paths across requests, because GitLab charges the query by how many", () => + Effect.gen(function* () { + const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockImplementation((request) => { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body = JSON.parse(request.stdin ?? "{}") as { + readonly variables: { readonly paths: ReadonlyArray }; + }; + return Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), + }, + }, + }, + }, + }), + ), + ); + }); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths, + }); + + assert.strictEqual(revisions.size, 150); + assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); + // The diff refs, then two batches: a hundred paths and the fifty left over. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 9f968dddbbc8..17da291425fd 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -35,8 +35,10 @@ import { decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, decodeProjectUsersJson, + decodeRepositoryBlobsJson, decodeViewerJson, gitLabAwardName, + REPOSITORY_BLOBS_GRAPHQL_QUERY, type GitLabDiffRefs, type GitLabMergeRequestDetail, type GitLabMergeRequestListItem, @@ -283,6 +285,20 @@ export class GitLabPullRequestCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + /** + * What the merge request's head has of each of these paths, as blob ids. + * + * The head sha comes from the merge request's own diff refs, so the answer is the version a + * reader is looking at rather than whatever the source branch has moved on to. A path the + * head does not have is left out. + */ + readonly getFileRevisions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect, GitLabPullRequestCliError>; + /** * Who this merge request may be sent to, and who it has already been sent to. Two reads at * once, because GitLab keeps the people with access on the project and the reviewers on the @@ -1026,6 +1042,67 @@ export const make = Effect.gen(function* () { }), ); + /** + * GitLab charges the blobs query by how many paths it is handed, and its connection hands back + * one page. A hundred at a time keeps each request inside both. + */ + const BLOB_PATHS_PER_REQUEST = 100; + + const blobsAt = (input: { + readonly cwd: string; + readonly repository: string; + readonly ref: string; + readonly paths: ReadonlyArray; + }): Effect.Effect, GitLabPullRequestCliError> => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: REPOSITORY_BLOBS_GRAPHQL_QUERY, + variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, + }), + }).pipe( + Effect.flatMap( + (result): Effect.Effect, GitLabPullRequestCliError> => { + const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getFileRevisions", + cause: decoded.failure, + }), + ); + }, + ), + ); + + const fileRevisions = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }): Effect.Effect, GitLabPullRequestCliError> => + input.paths.length === 0 + ? Effect.succeed(new Map()) + : getDiffRefs(input).pipe( + Effect.flatMap((refs) => { + const batches: Array> = []; + for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { + batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); + } + return Effect.forEach( + batches, + (paths) => blobsAt({ ...input, ref: refs.headSha, paths }), + { concurrency: 2 }, + ); + }), + Effect.map((pages) => new Map(pages.flatMap((page) => [...page]))), + ); + const viewerUsername = (input: { readonly cwd: string }) => api({ cwd: input.cwd, path: "user" }).pipe( Effect.flatMap((result): Effect.Effect => { @@ -1061,6 +1138,8 @@ export const make = Effect.gen(function* () { listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), + getFileRevisions: fileRevisions, + setReaction: (input) => Effect.gen(function* () { const subject = awardSubjectPath(input); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 701ef53b08ec..17788910bc45 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -35,6 +35,11 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["rebase"], search: true, reactions: true, + // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside + // that browser can read or write them. So the marks made here are this environment's own: they + // follow the reader between the clients connected to it, but they are not the ones gitlab.com + // shows, and the surface says so rather than implying a review can be carried on from there. + viewedFiles: "environment", review: { inlineComment: true, reply: true, @@ -222,6 +227,15 @@ export const make = Effect.gen(function* () { getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + // What each marked file is at the head, which is what tells a mark that still stands from one + // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, + // so this stales at the same moment its web UI would. + getFileRevisions: (input) => + cli.getFileRevisions(input).pipe( + Effect.mapError(fail("getFileRevisions")), + Effect.map((revisions) => ({ revisions })), + ), + // Users only: GitLab requests a review of a person, and the groups that can stand in for one // appear in approval rules rather than in a merge request's reviewers. listReviewerCandidates: (input) => diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 1ecba8c04224..bb5d2e135e23 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -208,6 +208,17 @@ export interface ProviderFilesViewed { readonly truncated: boolean; } +/** + * What version each of the asked-for files is at, on the change request's head. + * + * Opaque strings: the caller only ever compares one against another, and every host names a + * version its own way. A path the host answered nothing for is absent, which is the answer for a + * file the change request deletes rather than a failure to look. + */ +export interface ProviderFileRevisions { + readonly revisions: ReadonlyMap; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -363,16 +374,17 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is - * true, and read apart from the patch: a host that reports this at all reports it on a clock of - * its own, moving with every press rather than with every push. + * Which files the reader has already cleared. Only called when the host keeps that record + * itself — `capabilities.viewedFiles` of `"host"` — and read apart from the patch: a host that + * reports this at all reports it on a clock of its own, moving with every press rather than + * with every push. */ readonly getFilesViewed?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; /** - * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is true. + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. * * Takes several at once because that is how they are pressed. A provider whose host has no * bulk form still owes one round trip for the batch rather than one per file, since the point @@ -385,6 +397,22 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * What version the head has of each of these files. Required of a host whose + * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself. + * + * The marks live here, but what counts as the same file does not: only the host can say whether + * what a reader cleared last week is still what is in front of them. Asked for the marked paths + * alone, so the cost follows how much of the change request has been read rather than how large + * it is. + */ + readonly getFileRevisions?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly paths: ReadonlyArray; + }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 7903c3de6472..6882a9f6f563 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -11,6 +11,8 @@ import type { } from "@t3tools/contracts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -154,8 +156,10 @@ function makeService(input: { readonly providers: ReadonlyArray; readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; }) { - return PullRequestService.make.pipe( - Effect.provide( + // Built into the test's own scope rather than provided call by call: the marks store owns a + // database, and `Effect.provide` would close it the moment the service was handed back. + return Effect.flatMap( + Layer.build( Layer.mergeAll( Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -172,8 +176,12 @@ function makeService(input: { }), }), SourceControlRateLimit.layer, + // The real store over a database of its own, so the environment-kept marks are exercised + // through the SQL that holds them rather than through a stand-in that agrees with itself. + PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistenceMemory)), ), ), + (context) => Effect.provideContext(PullRequestService.make, context), ); } @@ -3440,7 +3448,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => mergeMethods: ["merge"], search: true, reactions: true, - viewedFiles: true, + viewedFiles: "host", review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -3473,6 +3481,184 @@ it.effect("keeps the diff cached across a file being ticked off", () => }), ); +const environmentViewedProvider = ( + revisions: Map, + asked: Array>, +) => + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of its own"), + setFilesViewed: () => Effect.die("the host keeps no marks of its own"), + getFileRevisions: (input) => { + asked.push(input.paths); + return Effect.succeed({ + revisions: new Map( + input.paths.flatMap((path) => { + const revision = revisions.get(path); + return revision === undefined ? [] : [[path, revision] as const]; + }), + ), + }); + }, + }); + +const environmentViewedService = ( + revisions: Map, + asked: Array>, +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [environmentViewedProvider(revisions, asked)], + }); + +const GITLAB_REFERENCE = { + projectId: "p1" as ProjectId, + repository: "group/project", + number: 1, +}; + +it.effect("keeps viewed files itself for a host that keeps none of its own", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService( + new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]), + asked, + ); + + // Nothing marked is nothing to ask the host about. + const empty = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual(empty, { files: [], truncated: false }); + assert.deepStrictEqual(asked, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: true }, + ], + }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + assert.strictEqual(marked.truncated, false); + // The marked paths alone, so the cost follows how much has been read rather than PR size. + assert.deepStrictEqual( + asked.map((paths) => [...paths].toSorted()), + [ + ["src/a.ts", "src/b.ts"], + ["src/a.ts", "src/b.ts"], + ], + ); + }), +); + +it.effect("reports a file pushed to since it was cleared as changed", () => + Effect.gen(function* () { + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: true }, + ], + }); + revisions.set("src/a.ts", "blob-a-again"); + // A push is not something the marks can hear about, so the reader asks to be re-answered. + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + }), +); + +it.effect("clears a mark again when the file is put back", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: false }], + }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, []); + // Unticking asks the host nothing: the row is going away whatever the head has. + assert.deepStrictEqual(asked, [["src/a.ts"]]); + }), +); + +it.effect("keeps a deleted file cleared, which the head has no version of at all", () => + Effect.gen(function* () { + const service = yield* environmentViewedService(new Map(), []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/gone.ts", viewed: true }], + }); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]); + }), +); + +it.effect("keeps environment marks apart from another change request's", () => + Effect.gen(function* () { + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); + + assert.deepStrictEqual(other.files, []); + }), +); + it.effect("refuses to track viewed files on a host that does not", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0467fd0f9a00..4492dab98b0b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,6 +1,7 @@ import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -51,6 +52,7 @@ import { import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -462,6 +464,9 @@ function withRateLimitBackoff( ...(api.setFilesViewed === undefined ? {} : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), + ...(api.getFileRevisions === undefined + ? {} + : { getFileRevisions: wrap("getFileRevisions", api.getFileRevisions) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -510,6 +515,7 @@ export const make = Effect.gen(function* () { const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; + const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; const refineUnknownProjectKinds = ( projects: ReadonlyArray, @@ -1309,23 +1315,142 @@ export const make = Effect.gen(function* () { }), ); + /** + * Which change request's marks, and whose. The host is part of it because the same + * `owner/repo` exists on more than one install, and the reader is part of it for the reason + * a host's own record is per-account. A host that will not say who is reading leaves it + * empty, which is one reader rather than none. + */ + const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ + provider: project.api.kind, + host: project.host, + repository: project.repository, + number, + viewer: viewer ?? "", + }); + + const toFilesViewedStoreError = (operation: string) => (cause: unknown) => + new PullRequestOperationError({ + operation, + detail: "This environment could not reach its record of which files you have seen.", + cause, + }); + + /** + * What the head has of these files, or null where the host cannot say. Null is not an error: + * without it the marks simply stop reporting staleness, which is worse than the host's own + * record but better than refusing to remember anything. + */ + const fileRevisionsOf = ( + project: SupportedProject, + number: number, + paths: ReadonlyArray, + operation: string, + ): Effect.Effect | null, PullRequestError> => { + const read = project.api.getFileRevisions; + return read === undefined + ? Effect.succeed(null) + : read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number, + paths, + }).pipe( + Effect.map((answer) => answer.revisions), + Effect.mapError(toPullRequestError(operation)), + ); + }; + + /** + * The marks this environment keeps for a host that keeps none of its own. + * + * A file the head still has at the revision it was cleared at is cleared; one the head has + * moved on from is reported as changed, which is what GitHub says of a file pushed to since it + * was ticked. Revisions are asked for the marked paths alone, so the cost follows how much of + * the change request has been read rather than how large it is, and a reader who has marked + * nothing costs no host call at all. + */ + const environmentFilesViewed = ( + project: SupportedProject, + number: number, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* viewerOf(project); + const marks = yield* filesViewedStore + .list(filesViewedScope(project, number, viewer)) + .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); + if (marks.length === 0) return { files: [], truncated: false }; + const revisions = yield* fileRevisionsOf( + project, + number, + marks.map((mark) => mark.path), + "filesViewed", + ); + return { + files: marks.map((mark) => ({ + path: mark.path, + // Absent reads as the empty revision on both sides, so a file the change request + // deletes is cleared once and stays cleared rather than reporting itself changed the + // moment it is ticked. + state: + revisions === null || (revisions.get(mark.path) ?? "") === mark.revision + ? ("viewed" as const) + : ("dismissed" as const), + })), + // Every mark is a row this environment holds, so there is no page to run out of. + truncated: false, + }; + }); + + const environmentSetFilesViewed = ( + project: SupportedProject, + input: PullRequestSetFilesViewedInput, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* viewerOf(project); + // Only the files being cleared need a revision. An unticked one is about to lose its row, + // and what the head has of it changes nothing about deleting it. + const cleared = input.files.filter((file) => file.viewed).map((file) => file.path); + const revisions = + cleared.length === 0 + ? null + : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed"); + const viewedAt = DateTime.formatIso(yield* DateTime.now); + yield* filesViewedStore + .set({ + ...filesViewedScope(project, input.number, viewer), + files: input.files.map((file) => ({ + path: file.path, + revision: revisions?.get(file.path) ?? "", + viewed: file.viewed, + })), + viewedAt, + }) + .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); + }); + const filesViewedUncached = (input: PullRequestRef) => requireProject(input).pipe( - Effect.flatMap((project) => { + Effect.flatMap((project): Effect.Effect => { const read = project.api.getFilesViewed; - return project.api.capabilities.viewedFiles === true && read - ? read({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - }).pipe(Effect.mapError(toPullRequestError("filesViewed"))) - : Effect.fail( - new PullRequestOperationError({ - operation: "filesViewed", - detail: "This host does not track which files a reader has seen.", - }), - ); + if (project.api.capabilities.viewedFiles === "host" && read) { + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); + } + if (project.api.capabilities.viewedFiles === "environment") { + return environmentFilesViewed(project, input.number); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "filesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); }), ); @@ -1333,20 +1458,24 @@ export const make = Effect.gen(function* () { requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { const write = project.api.setFilesViewed; - return project.api.capabilities.viewedFiles === true && write - ? write({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - files: input.files, - }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))) - : Effect.fail( - new PullRequestOperationError({ - operation: "setFilesViewed", - detail: "This host does not track which files a reader has seen.", - }), - ); + if (project.api.capabilities.viewedFiles === "host" && write) { + return write({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + files: input.files, + }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + } + if (project.api.capabilities.viewedFiles === "environment") { + return environmentSetFilesViewed(project, input); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); }), // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the // change request, and dropping a 300-file diff on every checkbox is the whole cost of diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 9221c1ab8e04..6751bdd97d20 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { decodeAwardEmojiJson, + decodeRepositoryBlobsJson, decodeCommitsJson, decodeMergeRequestDetailJson, decodeMergeRequestDiffsJson, @@ -608,3 +609,69 @@ describe("gitLabAwardName", () => { expect(gitLabAwardName("hooray")).toBe("tada"); }); }); + +describe("decodeRepositoryBlobsJson", () => { + it("reads a blob id per path", () => { + const blobs = expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: "aaa111" }, + { path: "src/b.ts", oid: "bbb222" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + ["src/a.ts", "aaa111"], + ["src/b.ts", "bbb222"], + ]); + }); + + it("leaves out a node missing either half, which names no version", () => { + const blobs = expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: null }, + { path: null, oid: "bbb222" }, + null, + { path: "src/c.ts", oid: "ccc333" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); + }); + + it("reads a project the reader cannot see as no blobs rather than a failure", () => { + // The revision simply has none of the asked-for files, which is what a caller reads as + // "nothing here still stands" rather than as a read that broke. + expect([ + ...expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), + ]).toEqual([]); + }); + + it("fails on output that is not the query's shape", () => { + expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); + expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 9f4bd96bae08..4479ff1bfad7 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -938,3 +938,77 @@ export function decodeOwnAwardIdJson( } return Result.succeed(null); } + +/** + * What the given paths are at one revision, as blob ids. + * + * Asked for by path rather than by walking the tree: the caller already knows which files it + * cares about, and GitLab charges this query by how many paths it is given. A path the revision + * does not have comes back missing rather than as an error, which is the answer for a file the + * merge request deletes. + */ +export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { + project(fullPath: $fullPath) { + repository { + blobs(ref: $ref, paths: $paths) { + nodes { path oid } + } + } + } +}`; + +const RawRepositoryBlobsSchema = Schema.Struct({ + data: Schema.Struct({ + project: Schema.NullOr( + Schema.Struct({ + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + blobs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + oid: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), + }), + ), + }), +}); + +const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); + +/** + * Blob ids by path. A node without both is left out: half an answer names no version, and the + * caller reads an absent path as "the revision does not have this file". + */ +export function decodeRepositoryBlobsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeRepositoryBlobs(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const blobs = new Map(); + for (const node of decoded.success.data.project?.repository?.blobs?.nodes ?? []) { + const path = trimmed(node?.path); + const oid = trimmed(node?.oid); + if (path === null || oid === null) continue; + blobs.set(path, oid); + } + return Result.succeed(blobs); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9a2c3fa10d6..9b167350e906 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -619,7 +619,11 @@ const buildAppUnderTest = (options?: { ); const servedRoutesLayer = HttpRouter.serve( - makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), + // Viewed-file marks for a host that keeps none of its own are rows, so the routes want a + // database. Its own, in memory: nothing here shares a table with the auth store. + makeRoutesLayer.pipe( + Layer.provide(Layer.mergeAll(serviceLauncherClientLayer, SqlitePersistenceMemory)), + ), { disableListenLog: true, disableLogger: true, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..c4081e62caae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -27,6 +27,7 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -447,6 +448,8 @@ const commandReadinessLayer = HttpRouter.middleware( const PullRequestServiceLive = PullRequestService.layer.pipe( // One registry entry per supported host; the service only knows the registry. Layer.provide(PullRequestProviderRegistry.layer), + // Where the viewed-file marks live for a host that keeps none of its own. + Layer.provide(PullRequestFilesViewed.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 23978183e825..1d5f8fe891b0 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -16,6 +16,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + InfoIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -394,12 +395,13 @@ export function PullRequestCodeTab({ ); const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]); // Offered under a commit scope as well as from the whole change, because reading a change one - // commit at a time is what the scope is for. The tick itself stays the host's: it is kept - // against the change request, so clearing a file here clears it everywhere. + // commit at a time is what the scope is for. The tick is kept against the change request rather + // than the scope it was made in, so clearing a file here clears it everywhere. + const viewedFilesStore = detail.capabilities.viewedFiles; const filesViewed = usePullRequestFilesViewed({ environmentId, reference, - enabled: detail.capabilities.viewedFiles === true, + enabled: viewedFilesStore !== undefined, paths: filePaths, }); const { setViewed, refresh: refreshFilesViewed } = filesViewed; @@ -1146,6 +1148,21 @@ export function PullRequestCodeTab({ {filesViewed.enabled && files.length > 0 ? ( {filesViewed.viewedCount} / {files.length} viewed + {viewedFilesStore === "environment" ? ( + + }> + + + + This host keeps no shared record of which files you have read, so these ticks + are kept by this environment. They follow you between the apps connected to it, + but the host's own web UI will not show them. + + + ) : null} {filesViewed.truncated ? ( }> diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 573b823b1170..f3c638ee5dba 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -28,10 +28,10 @@ const FLUSH_DELAY_MS = 400; const NO_OVERLAY: FileViewedOverlay = new Map(); export interface PullRequestFilesViewedView { - /** Whether the host tracks this at all, which is what hides the whole control. */ + /** Whether anything remembers this at all, which is what hides the whole control. */ readonly enabled: boolean; readonly isViewed: (path: string) => boolean; - /** The host says this file has been pushed to since it was cleared. */ + /** This file has been pushed to since it was cleared. */ readonly isStale: (path: string) => boolean; readonly setViewed: (path: string, viewed: boolean) => void; /** How many of the files on screen are ticked off. */ @@ -46,11 +46,13 @@ export interface PullRequestFilesViewedView { } /** - * Which files this reader has already cleared, as the host records it. + * Which files this reader has already cleared. * - * The state lives on the host rather than here so a review carried on from another machine, or - * from the host's own web UI, picks up where it was left. Presses show immediately and are held - * over the host's answer until it agrees with them, so the checkbox never waits on a round trip. + * The marks live on the server rather than in this tab, so a review carried on from another + * machine picks up where it was left. Where the host keeps a record of its own, those are the + * marks, and its web UI shows the same ones; where it does not, the environment keeps them and + * says so. Presses show immediately and are held over the server's answer until it agrees with + * them, so the checkbox never waits on a round trip. */ export function usePullRequestFilesViewed(options: { readonly environmentId: EnvironmentId; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1bf50e0cd1a1..e7f64142e042 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -62,8 +62,12 @@ T3 Code works with the platforms your team already uses: where you left it on the next, and in your browser too - If a file is pushed to after you cleared it, it comes back marked **Changed** so you know to look again -- GitHub only. GitLab, Bitbucket, and Azure DevOps do not keep this, so the checkbox is not shown - there +- On GitHub, the ticks are the ones GitHub keeps, so a review carries on between T3 Code and + github.com in either direction +- On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only + remembers them in one browser's own storage. They still follow you between the apps connected to + that server, but GitLab's own site will not show them. An info icon beside the count says so +- Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear there is cleared everywhere diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 598c7caf18ad..9f7335acc23f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -348,6 +348,19 @@ export const PullRequestReviewerCapabilities = Schema.Struct({ }); export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; +/** + * Who remembers which files a reader has cleared. + * + * `host` is the host's own record, so the marks are the ones its web UI shows and a review can be + * carried on from either side. `environment` is this server's record, for a host that keeps no + * shared one: GitLab holds its viewed files in one browser's local storage, where nothing outside + * that browser can read or write them, so marks made here are this environment's own. They still + * follow the reader between the clients connected to it, which is more than the host manages, but + * they are not the host's and the surface says so. + */ +export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]); +export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type; + /** * What a provider can actually do, so a surface can hide what is missing rather than offer an * action that would fail. Every provider fills this in for itself; nothing is assumed. @@ -385,15 +398,17 @@ export const PullRequestCapabilities = Schema.Struct({ */ reactions: Schema.optional(Schema.Boolean), /** - * A file can be marked as read by the person reading it, and the mark taken back. Optional for - * the same reason as `reactions`: a server that says nothing about it has none, which is what - * every server before this field was. + * Where the reader's own marks are kept, or absent where they are kept nowhere and the + * checkbox is not offered at all. Optional for the same reason as `reactions`: a server that + * says nothing about it has none, which is what every server before this field was. * - * True on GitHub alone so far. The others expose no equivalent, and a checkbox whose mark is - * forgotten the moment the tab closes is worse than no checkbox: it looks like the one beside - * it and keeps none of its promises. + * Two answers rather than a flag, because the surface has to say which one it is. A mark the + * host keeps is the same mark its own web UI shows; a mark this environment keeps is not, and + * a reader who ticks twenty files here and then opens the host would find none of them ticked. + * A checkbox that looks the same either way and quietly means different things is the failure + * this whole feature exists to avoid. */ - viewedFiles: Schema.optional(Schema.Boolean), + viewedFiles: Schema.optional(PullRequestViewedFilesStore), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /** From 53253a2056312806304210b79b4d7731f66abf1d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 10:06:25 -0400 Subject: [PATCH 15/25] fix(web): a GitLab reader can tell whose viewed marks these are The only signal that GitLab's own site would never show these ticks was a tooltip on a small icon, and the first reader to use it went looking for the marks on gitlab.com instead. The count says it now. Signed-off-by: Yordis Prieto --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 6 +++++- docs/user/source-control.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1d5f8fe891b0..9fce1ed1647e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -43,6 +43,7 @@ import { resolveFileDiffPreviousPath, type RenderablePatch, } from "~/lib/diffRendering"; +import { APP_BASE_NAME } from "~/branding"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { @@ -1147,7 +1148,10 @@ export function PullRequestCodeTab({ {filesViewed.enabled && files.length > 0 ? ( - {filesViewed.viewedCount} / {files.length} viewed + {/* Named on a host that keeps no record of its own, so the reader is told whose + ticks these are without having to find the icon beside them. */} + {filesViewed.viewedCount} / {files.length}{" "} + {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} {viewedFilesStore === "environment" ? ( }> diff --git a/docs/user/source-control.md b/docs/user/source-control.md index e7f64142e042..7c266a19ca39 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -66,7 +66,8 @@ T3 Code works with the platforms your team already uses: github.com in either direction - On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only remembers them in one browser's own storage. They still follow you between the apps connected to - that server, but GitLab's own site will not show them. An info icon beside the count says so + that server, but GitLab's own site will not show them. The count reads **viewed in T3 Code** so + you can tell at a glance, and an info icon beside it explains why - Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear From 1de680df62357882345f4965bc4f0bb4571dae01 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:09 -0400 Subject: [PATCH 16/25] fix(web): Azure DevOps pull request links open in the app The server derives a ref's repository from the project identity, and Azure DevOps is the one host where that is not the recorded path. Clients spelled it the other way, so the server turned their refs away at the door and the link fell through to the browser. The rule now lives where both sides can read it. Signed-off-by: Yordis Prieto --- .../src/pullRequest/PullRequestService.ts | 25 +++------ apps/web/src/components/ChatMarkdown.tsx | 3 +- apps/web/src/components/ChatView.tsx | 3 +- apps/web/src/lib/openPullRequestLink.test.ts | 22 ++++++++ apps/web/src/lib/openPullRequestLink.ts | 13 +++-- packages/contracts/src/pullRequest.test.ts | 53 +++++++++++++++++++ packages/contracts/src/pullRequest.ts | 27 ++++++++++ packages/shared/src/git.test.ts | 32 +++++++++++ packages/shared/src/git.ts | 38 ++++++++++--- 9 files changed, 183 insertions(+), 33 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 4492dab98b0b..5b4f0b0c79ae 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -11,6 +11,7 @@ import { PullRequestUnavailableError, pullRequestHostOf, pullRequestProviderRequirement, + pullRequestRepositoryOf, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type PullRequestAction, @@ -487,27 +488,13 @@ function withRateLimitBackoff( } /** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. + * The provider-native repository selector for a project, which everything downstream is keyed by: + * the rows' own `repository`, the per-repository cursors, and the detail and diff reads a row + * leads to. The rule itself is shared with the clients that build a ref, since a ref spelled any + * other way is refused before it reaches a provider. */ export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { - const identity = project.repositoryIdentity; - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - return identity.name || segments.at(-1) || null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; + return pullRequestRepositoryOf(project.repositoryIdentity); } export const make = Effect.gen(function* () { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd21a4e1bf40..18f9dbaa4ba4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import type { ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; +import { pullRequestRepositoryOf } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1761,7 +1762,7 @@ function ChatMarkdown({ if (project === undefined) return null; return { projectId: project.id, - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, url: href, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..d5e53154bb1a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, + pullRequestRepositoryOf, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -3476,7 +3477,7 @@ function ChatViewContent(props: ChatViewProps) { // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const activeProjectRepository = pullRequestRepositoryOf(activeProject?.repositoryIdentity); const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index c783f0fcc766..7b9bafb650ea 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -242,6 +242,28 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); + it("matches an Azure repository cloned over SSH, whose remote shares no part with its URL", () => { + // Azure alone addresses one repository under two names: `ssh.dev.azure.com` and `v3/...` over + // SSH against `dev.azure.com` and `.../_git/...` everywhere a person sees it. The identity is + // recorded in the spelling a link arrives in, so both halves of this comparison line up. + const projects = [ + project({ + canonicalKey: "dev.azure.com/t3tools/platform/_git/t3code", + provider: "azure-devops", + displayName: "t3tools/platform/_git/t3code", + owner: "t3tools", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "dev.azure.com", + repository: "t3tools/platform/_git/t3code", + number: 1, + }), + ).toBe(projects[0]); + }); + it("claims nothing for a lookalike host, which is what keeps a link a link", () => { const projects = [ project({ diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 22ee2938a032..888e3e8339d5 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -8,7 +8,11 @@ import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { + pullRequestHostOf, + pullRequestRepositoryOf, + type SourceControlProviderKind, +} from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { readLocalApi } from "../localApi"; @@ -262,9 +266,10 @@ export function useOpenChangeRequestLink( if (resolvedThreadRef) { useRightPanelStore.getState().openPullRequest(resolvedThreadRef, { projectId: project.id, - // The identity's own spelling, not the one read out of the URL: the panel asks the - // provider for this repository, while matching a link only ever compares lower case. - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + // The selector the server derives from the same identity, not the one read out of the + // URL: a ref spelled any other way is refused before it reaches a provider, and + // matching a link only ever compares lower case. + repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, }); return true; diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 4e54ca308a78..f96e755d3576 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -7,6 +7,7 @@ import { PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, + pullRequestRepositoryOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -230,3 +231,55 @@ describe("naming the reader as the author to narrow by", () => { expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me"); }); }); + +describe("the repository a ref names", () => { + const identity = (fields: Record) => + ({ canonicalKey: "example.test/repo", locator: {}, ...fields }) as never; + + it("names an Azure DevOps repository by itself, not by the project path around it", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; handed the recorded path it refuses, and the repository reads as unavailable. + expect( + pullRequestRepositoryOf( + identity({ + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }), + ), + ).toBe("checkout"); + }); + + it("falls back to the path's last segment where an Azure identity has no name", () => { + expect( + pullRequestRepositoryOf( + identity({ provider: "azure-devops", displayName: "contoso/payments/_git/checkout" }), + ), + ).toBe("checkout"); + }); + + it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { + expect( + pullRequestRepositoryOf( + identity({ + provider: "gitlab", + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }), + ), + ).toBe("group/subgroup/service"); + }); + + it("puts owner and name back together for an identity recorded before displayName", () => { + expect( + pullRequestRepositoryOf(identity({ provider: "github", owner: "t3tools", name: "t3code" })), + ).toBe("t3tools/t3code"); + }); + + it("names nothing for a project with no remote to name it by", () => { + expect(pullRequestRepositoryOf(null)).toBeNull(); + expect(pullRequestRepositoryOf(identity({ provider: "github" }))).toBeNull(); + }); +}); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 9f7335acc23f..c065837df8af 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -9,6 +9,7 @@ import { ProjectId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { type RepositoryIdentity } from "./environment.ts"; import { SourceControlProviderKind } from "./sourceControl.ts"; export const PullRequestInvolvement = Schema.Literals(["all", "reviewing", "authored"]); @@ -615,6 +616,32 @@ export const PullRequestRef = Schema.Struct({ }); export type PullRequestRef = typeof PullRequestRef.Type; +/** + * The `repository` a {@link PullRequestRef} carries, read off the project's recorded identity. + * + * `displayName` is the full path below the host, which is what nested GitLab groups need; + * owner/name is the two-segment fallback for identities recorded before that field existed. + * + * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and + * takes the organisation and project from the checkout it detects, so the recorded + * `org/project/_git/repo` path is refused outright and the whole repository reads as + * unavailable. Its name is the last segment, which is what this hands over. + * + * Shared rather than server-only because the server checks a ref's `repository` against the one + * it derives here, so a client that spells it any other way is turned away at the door. + */ +export function pullRequestRepositoryOf( + identity: RepositoryIdentity | null | undefined, +): string | null { + if (!identity) return null; + if (identity.provider === "azure-devops") { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + return identity.name || segments.at(-1) || null; + } + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + /** * One row's line counts, read after the listing rather than inside it. On GitHub the pair is * 40-60% of the wall clock of the search that answers the whole page — measured over twelve diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..c3f1c024fe6f 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -49,6 +49,38 @@ describe("normalizeGitRemoteUrl", () => { "bitbucket.org/workspace/repo", ); }); + + it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + expect( + normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"), + ).toBe("dev.azure.com/t3tools/platform/_git/t3code"); + }); + + it("puts the organization back in the host on the name dev.azure.com replaced", () => { + expect( + normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"), + ).toBe("t3tools.visualstudio.com/platform/_git/t3code"); + expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe( + "t3tools.visualstudio.com/platform/_git/t3code", + ); + }); + + it("leaves an Azure SSH host it cannot read as the path it was given", () => { + // Not `v3`, and not four segments: rewriting either would invent a repository that the web + // spelling has no name for, so the remote stands as it arrived. + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v4/T3Tools/Platform/T3Code")).toBe( + "ssh.dev.azure.com/v4/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/T3Code")).toBe( + "ssh.dev.azure.com/v3/t3tools/t3code", + ); + }); }); describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..4b5107cf51bc 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -108,6 +108,26 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } +/** + * The web spelling of an Azure DevOps repository reached over SSH, or null for anything else. + * + * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and + * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` + * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository + * to every comparison made against a pull request URL, which arrives in the web spelling. So the + * web spelling is the one both are keyed by. + */ +function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray): string | null { + if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null; + const [marker, organization, project, repository] = segments; + if (segments.length !== 4 || marker !== "v3") return null; + if (!organization || !project || !repository) return null; + // The organization leads the host on the name dev.azure.com replaced, and the path below it. + return host === "ssh.dev.azure.com" + ? `dev.azure.com/${organization}/${project}/_git/${repository}` + : `${organization}.visualstudio.com/${project}/_git/${repository}`; +} + /** * Normalize a git remote URL into a stable comparison key. */ @@ -121,12 +141,12 @@ export function normalizeGitRemoteUrl(value: string): string { if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { try { const url = new URL(normalized); - const repositoryPath = url.pathname - .split("/") - .filter((segment) => segment.length > 0) - .join("/"); - if (url.hostname && repositoryPath.includes("/")) { - return `${url.hostname}/${repositoryPath}`; + const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0); + if (url.hostname && repositorySegments.length > 1) { + return ( + azureDevOpsRepositoryKey(url.hostname, repositorySegments) ?? + `${url.hostname}/${repositorySegments.join("/")}` + ); } } catch { return normalized; @@ -136,8 +156,10 @@ export function normalizeGitRemoteUrl(value: string): string { const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( normalized, ); - if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { - return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + const scpHost = scpStyleHostAndPath?.[1]; + const scpPath = scpStyleHostAndPath?.[2]; + if (scpHost && scpPath) { + return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`; } return normalized; From 087641590693352afa64e4ea340169e0984c62cc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:15 -0400 Subject: [PATCH 17/25] feat(server): Bitbucket reviewers keep their place in a long review Bitbucket records nothing about what a reviewer has already read, so the marks are this environment's own. Without a revision to compare against they could not tell a file still as it was read from one pushed to since, which is the distinction that makes the marks worth keeping at all. Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 81 +++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 101 ++++++++++--- .../BitbucketPullRequestProvider.ts | 17 +++ .../bitbucketDiffRevisions.test.ts | 133 ++++++++++++++++++ .../src/pullRequest/bitbucketDiffRevisions.ts | 98 +++++++++++++ 5 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.ts diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 8945ecc5e1e2..7cdec6dc9a92 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -362,6 +362,87 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect("reads file versions out of the patch, for the paths it was asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-a", + "+b", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -1 +1 @@", + "-c", + "+d", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 71, + paths: ["a.ts", "missing.ts"], + }); + + // `b.ts` is in the patch and was not asked about, and `missing.ts` was asked about and is + // not in the patch. Neither belongs in the answer. + assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); + expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); + }), + ); + + it.effect("re-reads the patch once for a burst of presses rather than once per press", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const first = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts"], + }); + const second = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts"], + }); + + assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); + assert.deepStrictEqual([...second], [["a.ts", "2222222"]]); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("asks Bitbucket nothing when no file has been ticked off", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 73, + paths: [], + }); + + assert.strictEqual(revisions.size, 0); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + it.effect("aggregates every diffstat page", () => Effect.gen(function* () { const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 5b3149b0d75c..b841210b6a6f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,5 +1,8 @@ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -19,6 +22,7 @@ import type { } from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; import { buildReviewThreads, decodeCommentsJson, @@ -135,6 +139,14 @@ const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; +/** + * How long the versions read out of a patch stand for. The same window the diff itself is held + * for, deliberately: the patch on screen and what it is said to be at must not disagree, and a + * reader ticking their way down a file list would otherwise re-read the whole patch per press. + */ +const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); +/** Pull requests held at once, which is more than anyone has open. */ +const FILE_REVISIONS_CACHE_CAPACITY = 32; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; @@ -182,6 +194,19 @@ export class BitbucketPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; + /** + * What the pull request's head has of each of these paths, as opaque ids. + * + * Read off the pull request's own patch, the only place Bitbucket states a file's version, and + * held briefly so that ticking files off does not re-read it per press. Paths the patch says + * nothing about are left out. + */ + readonly getFileRevisions: (input: { + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect, BitbucketPullRequestApiError>; + readonly getMergeability: (input: { readonly repository: string; readonly number: number; @@ -524,6 +549,47 @@ export const make = Effect.gen(function* () { }), ); + const pullRequestDiff = (input: { + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + }): Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + > => + input.commit !== undefined && !isCommitSha(input.commit) + ? Effect.fail(new BitbucketDiffCommitError()) + : withRepository(input.repository, (path) => + // Already a unified patch, so it needs no decoding at all — only a bound, which a + // diff of any size would otherwise ignore. A commit's own patch sits beside the pull + // request's at `/diff/{sha}` and reads the same way. + bitbucket + .request({ + method: "GET", + url: + input.commit === undefined + ? `${path}/pullrequests/${input.number}/diff` + : `${path}/diff/${input.commit}`, + maxBytes: DIFF_MAX_BYTES, + }) + .pipe( + Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), + ), + ); + + const fileRevisionsCache = yield* Cache.makeWith( + (key: string) => { + const [repository, number] = JSON.parse(key) as [string, number]; + return pullRequestDiff({ repository, number }).pipe( + Effect.map((diff) => parseDiffFileRevisions(diff.patch)), + ); + }, + { + capacity: FILE_REVISIONS_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? FILE_REVISIONS_CACHE_TTL : Duration.zero), + }, + ); + return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -595,25 +661,22 @@ export const make = Effect.gen(function* () { }), ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), - getPullRequestDiff: (input) => - input.commit !== undefined && !isCommitSha(input.commit) - ? Effect.fail(new BitbucketDiffCommitError()) - : withRepository(input.repository, (path) => - // Already a unified patch, so it needs no decoding at all — only a bound, which a - // diff of any size would otherwise ignore. A commit's own patch sits beside the pull - // request's at `/diff/{sha}` and reads the same way. - bitbucket - .request({ - method: "GET", - url: - input.commit === undefined - ? `${path}/pullrequests/${input.number}/diff` - : `${path}/diff/${input.commit}`, - maxBytes: DIFF_MAX_BYTES, - }) - .pipe( - Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), - ), + getPullRequestDiff: pullRequestDiff, + + getFileRevisions: (input) => + input.paths.length === 0 + ? Effect.succeed(new Map()) + : Cache.get(fileRevisionsCache, JSON.stringify([input.repository, input.number])).pipe( + Effect.map((all) => { + // Narrowed to what was asked for rather than handed back whole: the caller compares + // the paths it named, and a patch of a thousand files has no business in its answer. + const asked = new Map(); + for (const path of input.paths) { + const revision = all.get(path); + if (revision !== undefined) asked.set(path, revision); + } + return asked; + }), ), getDiffStat: (input) => diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e7a9a6b6ddd9..93c7f0610757 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -31,6 +31,11 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a + // viewed file, and the per-pull-request properties it does offer are one value shared by + // everyone rather than one per reader. So the marks are kept here, and the client says whose + // they are rather than implying bitbucket.org will show them. + viewedFiles: "environment", }; /** @@ -233,6 +238,18 @@ export const make = Effect.gen(function* () { Effect.map((diff) => ({ ...diff, nextCursor: null })), ), + getFileRevisions: (input) => + api + .getFileRevisions({ + repository: input.repository, + number: input.number, + paths: input.paths, + }) + .pipe( + Effect.mapError(fail("getFileRevisions")), + Effect.map((revisions) => ({ revisions })), + ), + // Users only: Bitbucket requests a review of an account, and has no group that stands in for // one on a pull request. listReviewerCandidates: (input) => diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts new file mode 100644 index 000000000000..572548309b75 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts @@ -0,0 +1,133 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; + +function patchOf(...lines: ReadonlyArray): string { + return `${lines.join("\n")}\n`; +} + +describe("parseDiffFileRevisions", () => { + it("reads the head id of a changed file off its index line", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/src/a.ts b/src/a.ts", + "index 7f2aa0ab6..b4a2a7c9a 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]); + }); + + it("names a deletion by the path it had, which is the one still on screen", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/gone.ts b/gone.ts", + "deleted file mode 100644", + "index 1111111..0000000", + "--- a/gone.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-x", + ), + ); + + assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]); + }); + + it("names a rename by where it moved to", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/old.ts b/new.ts", + "similarity index 90%", + "rename from old.ts", + "rename to new.ts", + "index 2222222..3333333 100644", + "--- a/old.ts", + "+++ b/new.ts", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]); + }); + + it("names a rename that changed nothing, which states no paths of its own", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/old.ts b/new.ts", + "similarity index 100%", + "rename from old.ts", + "rename to new.ts", + "index 2222222..2222222 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]); + }); + + it("leaves out a file Bitbucket excluded, which it gives no index line for", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/package.json b/package.json", + "index 7f2aa0ab6..b4a2a7c9a 100644", + "--- a/package.json", + "+++ b/package.json", + "@@ -1 +1 @@", + '- "x": "1"', + '+ "x": "2"', + "diff --git a/yarn.lock b/yarn.lock", + 'File excluded by pattern "yarn.lock"', + ), + ); + + assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]); + }); + + it("stops reading headers at the first hunk, so content cannot pose as one", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/notes.md b/notes.md", + "index aaaaaaa..bbbbbbb 100644", + "--- a/notes.md", + "+++ b/notes.md", + "@@ -1,2 +1,2 @@", + "--- a/decoy.ts", + "+++ b/decoy.ts", + "+index ccccccc..ddddddd 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]); + }); + + it("splits a header whose paths contain the separator, by the sides agreeing", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/one b/two.ts b/one b/two.ts", + "index eeeeeee..fffffff 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]); + }); + + it("leaves out an added file that Bitbucket sent no index line for", () => { + const revisions = parseDiffFileRevisions( + patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"), + ); + + assert.strictEqual(revisions.size, 0); + }); + + it("reads nothing out of an empty patch", () => { + assert.strictEqual(parseDiffFileRevisions("").size, 0); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts new file mode 100644 index 000000000000..af915bc9e069 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -0,0 +1,98 @@ +const ENTRY = "diff --git "; + +interface Entry { + oldPath: string | null; + newPath: string | null; + deleted: boolean; + revision: string | null; + /** Past the first hunk header every line is content, and content can start like a header. */ + inBody: boolean; +} + +/** `a/x` and `b/x` on a `---` or `+++` line; `/dev/null` is the side that has no file. */ +function sidePath(rest: string, prefix: string): string | null { + if (rest === "/dev/null") return null; + return rest.startsWith(prefix) ? rest.slice(prefix.length) : rest; +} + +/** + * The two names on a `diff --git` line, which git writes with no delimiter between them. + * + * `a/one two b/one two` splits in more than one place, so the split that leaves both sides equal + * wins. A rename is the only entry whose sides differ, and a rename states its names on lines of + * its own. Anything still ambiguous is left unnamed rather than guessed at. + */ +function headerPaths(rest: string): readonly [string | null, string | null] { + if (!rest.startsWith("a/")) return [null, null]; + const splits: Array = []; + for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at); + const chosen = + splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ?? + (splits.length === 1 ? splits[0] : undefined); + return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)]; +} + +/** The right-hand id of `index .. `. */ +function headRevision(rest: string): string | null { + const gap = rest.indexOf(".."); + if (gap === -1) return null; + const after = rest.slice(gap + 2); + const end = after.indexOf(" "); + const head = end === -1 ? after : after.slice(0, end); + return head.length === 0 ? null : head; +} + +/** + * What the head has of each file in a unified patch, as the blob ids git writes into it. + * + * Bitbucket states a file's version nowhere else: its diffstat entries carry a commit and a path + * and no blob id, and no endpoint answers what a file is now. Git's own `index ..` + * line is in the patch the diff already reads, so the versions cost no call of their own. + * + * Keyed the way the client names files: the head's name for it, except for a deletion, where the + * head has no name and the one it had is what is on screen. An entry the patch gives no `index` + * line for, one Bitbucket excluded by pattern most often, is left out. Left out reads the same + * way when a file is ticked and when the tick is read back, so the mark still holds. + */ +export function parseDiffFileRevisions(patch: string): ReadonlyMap { + const revisions = new Map(); + let entry: Entry | null = null; + + const close = () => { + if (entry === null) return; + const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath); + if (path !== null && path.length > 0 && entry.revision !== null) { + revisions.set(path, entry.revision); + } + entry = null; + }; + + for (const line of patch.split("\n")) { + if (line.startsWith(ENTRY)) { + close(); + const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length)); + entry = { oldPath, newPath, deleted: false, revision: null, inBody: false }; + continue; + } + if (entry === null || entry.inBody) continue; + if (line.startsWith("@@")) { + entry.inBody = true; + } else if (line.startsWith("index ")) { + entry.revision = headRevision(line.slice("index ".length)); + } else if (line.startsWith("deleted file mode")) { + entry.deleted = true; + } else if (line.startsWith("rename from ")) { + entry.oldPath = line.slice("rename from ".length); + } else if (line.startsWith("rename to ")) { + entry.newPath = line.slice("rename to ".length); + } else if (line.startsWith("--- ")) { + entry.oldPath = sidePath(line.slice(4), "a/"); + } else if (line.startsWith("+++ ")) { + const side = sidePath(line.slice(4), "b/"); + entry.newPath = side; + if (side === null) entry.deleted = true; + } + } + close(); + return revisions; +} From 9146a44b7c88fa88c2c32a260b87ba22d849b1f1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 14:54:25 -0400 Subject: [PATCH 18/25] feat(server): Azure DevOps pull requests show their files The adapter never read what a pull request changed, so the panel reported no files and the Code tab was hidden outright. Azure serves no patch of its own, and its record of what a reviewer has read sits behind an endpoint it has never released, so both are answered from what it does state: the files an iteration changed, and the blob each side holds. Reading the conversation moved off `az rest` in the process. It mints its own token against whichever tenant `az` defaults to, which is not the one an organisation necessarily lives in, so that read had been failing wherever the two differ. Signed-off-by: Yordis Prieto --- apps/server/package.json | 1 + .../AzureDevOpsPullRequestCli.test.ts | 113 ++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 169 +++++++++-- .../AzureDevOpsPullRequestProvider.ts | 284 +++++++++++++++--- .../src/pullRequest/azureDevOpsDiff.test.ts | 148 +++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 139 +++++++++ .../azureDevOpsPullRequestJson.test.ts | 141 ++++++++- .../pullRequest/azureDevOpsPullRequestJson.ts | 185 +++++++++++- docs/user/source-control.md | 13 +- pnpm-lock.yaml | 3 + 10 files changed, 1102 insertions(+), 94 deletions(-) create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.test.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.ts diff --git a/apps/server/package.json b/apps/server/package.json index 4d17229cd3af..307a6c311d59 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", + "diff": "8.0.3", "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 5baf18a1ff6a..d6da118936bc 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -476,6 +476,105 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("names the head's blob as what a cleared file was cleared at", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + { + id: 2, + sourceRefCommit: { commitId: "c".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // Kept here rather than on Azure: its own record of what a reader has read is behind an + // undocumented endpoint, so the marks belong to this environment and need a revision of + // their own to tell a re-push from a file still as it was read. + assert.strictEqual(provider.capabilities.viewedFiles, "environment"); + assert.isDefined(provider.getFileRevisions); + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + // The latest push, since an iteration's changes are reported against the merge base rather + // than against the push before it. + expect(argsOfCall(2)).toContain("iterationId=2"); + // Only what was asked for. DEMO.md changed too, and nobody has marked it. + expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + + it.effect("leaves out a marked file the pull request no longer changes", () => + Effect.gen(function* () { + // Which reads as the empty revision, the same thing stored for a file that had none when it + // was ticked. A file the pull request deletes is cleared once and stays cleared. + mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: [], + }); + + expect(answer.revisions.size).toBe(0); + // Nothing was marked, so Azure was not asked at all. + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + it.effect("reads the conversation through the REST API, pinned to a version", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -499,14 +598,18 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const comments = yield* cli.listThreads({ cwd: "/w", - threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads", + location: { project: "platform", repository: "web" }, + number: 42, }); assert.strictEqual(comments.length, 1); - expect(argsOfCall(0)).toContain("rest"); - expect(argsOfCall(0)).toContain( - "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1", - ); + // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension + // does, and `az rest` mints its own token against whichever tenant `az` defaults to. + expect(argsOfCall(0)).toContain("invoke"); + expect(argsOfCall(0)).toContain("pullRequestThreads"); + expect(argsOfCall(0)).toContain("project=platform"); + expect(argsOfCall(0)).toContain("repositoryId=web"); + expect(argsOfCall(0)).toContain("pullRequestId=42"); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 549a172b3646..a77bba0adf6b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -13,11 +13,17 @@ import type { import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, decodeViewerJson, + type AzureDevOpsChangeEntry, + type AzureDevOpsIteration, type AzureDevOpsPullRequest, + type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; @@ -149,9 +155,42 @@ export class AzureDevOpsPullRequestCli extends Context.Service< /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ readonly listThreads: (input: { readonly cwd: string; - readonly threadsUrl: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + /** + * The pushes a pull request has had, oldest first. Azure hangs the changed files off an + * iteration rather than off the pull request, so reading a diff starts here. + */ + readonly listIterations: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * What one iteration changed, against the merge base rather than against the previous push, + * which is the whole of the pull request rather than the latest slice of it. + */ + readonly listIterationChanges: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterationId: number; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + /** + * One file's text at one commit. Azure has no diff route that carries content, so both sides + * of every changed file are read this way and the patch is made from them here. + */ + readonly readItemContent: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly path: string; + readonly commit: string; + }) => Effect.Effect; + readonly runPullRequestAction: (input: { readonly cwd: string; readonly number: number; @@ -264,6 +303,68 @@ export const make = Effect.gen(function* () { args: [...input.args, "--only-show-errors", "--output", "json"], }); + /** + * A REST route reached through `az devops invoke`, which addresses it by area, resource and + * route parameters rather than by URL. It is used in place of `az rest` because it signs in the + * way the azure-devops extension does, and `az rest` mints its own token against the tenant `az` + * defaults to. For an organisation in any other tenant that token is rejected and Azure answers + * with a sign-in page, which arrives here as unreadable output rather than as a failure. + */ + const invoke = (input: { + readonly cwd: string; + readonly operation: string; + readonly resource: string; + readonly routeParameters: ReadonlyArray; + readonly queryParameters?: ReadonlyArray; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + executeJson({ + cwd: input.cwd, + args: [ + "devops", + "invoke", + ...detectArgs, + "--area", + "git", + "--resource", + input.resource, + "--api-version", + REST_API_VERSION, + "--route-parameters", + ...input.routeParameters, + ...(input.queryParameters === undefined + ? [] + : ["--query-parameters", ...input.queryParameters]), + ], + }).pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ + `project=${location.project}`, + `repositoryId=${location.repository}`, + ]; + + const pullRequestRoute = (input: { + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + }): ReadonlyArray => [ + ...repositoryRoute(input.location), + `pullRequestId=${input.number}`, + ]; + /** * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and * retain the raw count so the next public cursor skips every row this walk consumed. @@ -430,30 +531,52 @@ export const make = Effect.gen(function* () { ), listThreads: (input) => - executeJson({ + invoke({ + cwd: input.cwd, + operation: "listThreads", + resource: "pullRequestThreads", + routeParameters: pullRequestRoute(input), + decode: decodeThreadsJson, + }), + + listIterations: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterations", + resource: "pullRequestIterations", + routeParameters: pullRequestRoute(input), + decode: decodeIterationsJson, + }), + + listIterationChanges: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterationChanges", + resource: "pullRequestIterationChanges", + routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], + // Azure pages this route at 1000 entries by default. A review that large is already past + // what the client will render, and the ceiling is Azure's own maximum for the route. + queryParameters: ["$top=2000"], + decode: decodeIterationChangesJson, + }), + + readItemContent: (input) => + invoke({ cwd: input.cwd, - args: [ - "rest", - "--method", - "get", - "--url", - `${input.threadsUrl}?api-version=${REST_API_VERSION}`, + operation: "readItemContent", + resource: "items", + routeParameters: repositoryRoute(input.location), + queryParameters: [ + `path=${input.path}`, + "versionDescriptor.versionType=commit", + `versionDescriptor.version=${input.commit}`, + "includeContent=true", + // Without this Azure answers with the file's own bytes rather than with a JSON + // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. + "$format=json", ], - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeThreadsJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new AzureDevOpsPullRequestReadError({ - command: "az", - cwd: input.cwd, - operation: "listThreads", - cause: decoded.failure, - }), - ); - }), - ), + decode: decodeItemContentJson, + }), setPullRequestReviewers: (input) => input.reviewers.some((reviewer) => !isReviewerName(reviewer)) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 631fee971cc1..1fe7f60f66fb 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -2,20 +2,33 @@ import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + azureDevOpsFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, + MAX_DIFF_SLICE_BYTES, + type AzureDevOpsFileTexts, +} from "./azureDevOpsDiff.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, type ProviderChangeRequest, type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, + type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; +import type { + AzureDevOpsChangeEntry, + AzureDevOpsIteration, + AzureDevOpsPullRequest, + AzureDevOpsRepositoryLocation, +} from "./azureDevOpsPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { - // `az repos pr` has no diff command, and the REST route reports changed files without their - // contents, so there is no patch to show. The Code tab is hidden rather than empty. - diff: false, + // Azure serves no patch of its own, so the one the Code tab reads is built here out of the + // files an iteration changed and both sides of each of them. + diff: true, // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, @@ -33,7 +46,8 @@ const CAPABILITIES: PullRequestCapabilities = { // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, reactions: false, - // With no patch to show there are no lines to write against, so nothing here is offered. + // The patch has lines to write against, but writing a remark at all is what Azure is not + // offered for here, so nothing in a review is either. review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` // lists the ones this repository could name — that lives behind the identity and graph APIs, a @@ -44,6 +58,11 @@ const CAPABILITIES: PullRequestCapabilities = { // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on // Azure DevOps at all, so there is nothing here it could rewrite either. edit: { changeRequest: true, comment: false }, + // Azure does keep a viewed record of its own, but only behind the undocumented contribution + // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. + // So they are kept here instead, and the client says whose they are rather than implying the + // Azure DevOps page will show them. + viewedFiles: "environment", }; /** @@ -85,8 +104,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq state: pullRequest.state, isDraft: pullRequest.isDraft, mergeability: pullRequest.mergeability, - // Azure reports no line counts on a pull request, and with no patch to read there is - // nothing to count them from either. + // Azure counts a pull request's files but never its lines, and counting them here would mean + // reading every file on both sides of every row of a listing. additions: 0, deletions: 0, createdAt: pullRequest.createdAt, @@ -121,6 +140,82 @@ export const make = Effect.gen(function* () { }), ); + /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ + const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; + + /** + * Everything a diff read needs before it can ask for a file: where the repository lives, and + * which pushes the pull request has had. A client names neither, and the pull request read is + * the only place Azure states the first. + */ + const diffScope = (input: { readonly cwd: string; readonly number: number }) => + Effect.gen(function* () { + const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); + const location = pullRequest.location; + if (location === null) return null; + const iterations = yield* cli.listIterations({ + cwd: input.cwd, + location, + number: input.number, + }); + return { location, iterations }; + }); + + /** + * Both sides of one changed file. Only the sides a change actually has are asked for: Azure + * answers for a file that is not at a commit with a failure rather than with nothing. + */ + const readTexts = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly iteration: AzureDevOpsIteration; + readonly change: Pick; + }) => + Effect.gen(function* () { + const oldContents = + input.change.changeKind === "new" + ? "" + : yield* cli.readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.oldPath, + commit: input.iteration.mergeBaseCommit, + }); + const newContents = + input.change.changeKind === "deleted" + ? "" + : yield* cli.readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.path, + commit: input.iteration.headCommit, + }); + const texts: AzureDevOpsFileTexts = { oldContents, newContents }; + return texts; + }); + + /** + * What the whole pull request changed, taken from its latest push. An iteration's changes are + * reported against the merge base rather than against the push before it, so the newest one is + * the whole of the change rather than the last slice of it. + */ + const listLatestChanges = (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly iterations: ReadonlyArray; + }) => { + const latest = input.iterations.at(-1); + return latest === undefined + ? Effect.succeed([] as ReadonlyArray) + : cli.listIterationChanges({ + cwd: input.cwd, + location: input.location, + number: input.number, + iterationId: latest.id, + }); + }; + const provider: PullRequestProviderApi = { kind: "azure-devops", capabilities: CAPABILITIES, @@ -155,34 +250,57 @@ export const make = Effect.gen(function* () { ), getChangeRequest: (input) => - cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.mapError(fail("getChangeRequest")), - Effect.map( - (pullRequest): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - body: pullRequest.body, - changedFiles: 0, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - reviewers: pullRequest.reviewers, - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: false }, - viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, - autoMergeEnabled: pullRequest.autoMergeEnabled, - }), - ), - ), + Effect.gen(function* () { + const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); + const location = pullRequest.location; + // The file count is two reads past the pull request itself, and it is the only thing + // riding on them, so a failure leaves it unknown rather than losing the whole detail. + const changedFiles = + location === null + ? 0 + : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe( + Effect.flatMap((iterations) => + listLatestChanges({ + cwd: input.cwd, + location, + number: input.number, + iterations, + }), + ), + Effect.map((changes) => changes.length), + Effect.orElseSucceed(() => 0), + ); + const detail: ProviderChangeRequestDetail = { + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + }; + return detail; + }).pipe(Effect.mapError(fail("getChangeRequest"))), getChangeRequestActivity: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequestActivity")), Effect.flatMap((pullRequest) => - (pullRequest.threadsUrl === null + (pullRequest.location === null ? Effect.succeed({ comments: [], truncated: true }) - : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe( - Effect.map((comments) => ({ comments, truncated: false })), - Effect.orElseSucceed(() => ({ comments: [], truncated: true })), - ) + : cli + .listThreads({ + cwd: input.cwd, + location: pullRequest.location, + number: input.number, + }) + .pipe( + Effect.map((comments) => ({ comments, truncated: false })), + Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + ) ).pipe( Effect.map( (conversation): ProviderChangeRequestActivity => ({ @@ -201,16 +319,104 @@ export const make = Effect.gen(function* () { // reach, so the answer is the same constant the detail carries. getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), - // Never called: `capabilities.diff` is false, and the service refuses a diff without it. - getDiff: () => - Effect.fail( - new PullRequestProviderError({ - provider: "azure-devops", - operation: "getDiff", - reason: "failed", - detail: "Azure DevOps cannot produce a patch for a pull request.", - }), - ), + // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so + // the Code tab has nothing to scope itself to and always asks for the whole change. + getDiff: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + if (scope === null) return EMPTY_DIFF_SLICE; + const cursor = parseAzureDevOpsDiffCursor(input.cursor); + // Reading on stays with the push the first slice was taken against. A push landing + // mid-read would otherwise renumber the files and hand the reader one twice, or none. + const iteration = + cursor === null + ? scope.iterations.at(-1) + : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); + if (iteration === undefined) return EMPTY_DIFF_SLICE; + const changes = yield* cli.listIterationChanges({ + cwd: input.cwd, + location: scope.location, + number: input.number, + iterationId: iteration.id, + }); + + const sections: string[] = []; + let truncated = false; + let bytes = 0; + let index = cursor?.fileIndex ?? 0; + while (index < changes.length) { + const change = changes.at(index); + if (change === undefined) break; + const texts = yield* readTexts({ + cwd: input.cwd, + location: scope.location, + iteration, + change, + }); + const file = azureDevOpsFilePatch({ change, texts }); + sections.push(file.section); + bytes += file.section.length; + truncated = truncated || file.truncated; + index += 1; + if (bytes >= MAX_DIFF_SLICE_BYTES) break; + } + + const slice: ProviderDiffSlice = { + patch: sections.join(""), + truncated, + nextCursor: + index >= changes.length + ? null + : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }), + }; + return slice; + }).pipe(Effect.mapError(fail("getDiff"))), + + // The patch is built from whole files, so opening the lines around a hunk is the same two + // reads over again rather than a wider request. + getDiffFileContents: (input) => + Effect.gen(function* () { + const scope = yield* diffScope(input); + const iteration = scope?.iterations.at(-1); + if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" }; + return yield* readTexts({ + cwd: input.cwd, + location: scope.location, + iteration, + change: { + changeKind: input.changeType, + path: input.newPath, + oldPath: input.oldPath, + }, + }); + }).pipe(Effect.mapError(fail("getDiffFileContents"))), + + /** + * What the head has of each marked file, which is the blob Azure already names on the change + * it reports. One read covers every path: the latest iteration lists the whole change, so + * asking per file would be the same answer fetched over and over. + * + * A path the change no longer carries is left out rather than guessed at, which reads as the + * empty revision and leaves a file the pull request deletes cleared once and cleared for good. + */ + getFileRevisions: (input) => + Effect.gen(function* () { + const revisions = new Map(); + if (input.paths.length === 0) return { revisions }; + const scope = yield* diffScope(input); + if (scope === null) return { revisions }; + const changes = yield* listLatestChanges({ + ...scope, + cwd: input.cwd, + number: input.number, + }); + const marked = new Set(input.paths); + for (const change of changes) { + if (!marked.has(change.path) || change.objectId === null) continue; + revisions.set(change.path, change.objectId); + } + return { revisions }; + }).pipe(Effect.mapError(fail("getFileRevisions"))), runAction: (input) => cli diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts new file mode 100644 index 000000000000..1beba81e3660 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + azureDevOpsFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +function change(overrides: Partial = {}): AzureDevOpsChangeEntry { + return { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8f80", + originalObjectId: "0ca4", + ...overrides, + }; +} + +describe("azureDevOpsFilePatch", () => { + it("writes a changed file as the unified patch every diff viewer already reads", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\ntwo\nthree\n", newContents: "one\ntwo again\nthree\n" }, + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1,3 +1,3 @@", + " one", + "-two", + "+two again", + " three", + "", + ].join("\n"), + ); + }); + + it("names the side a new file does not have as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: { oldContents: "", newContents: "hello\n" }, + }); + + expect(patch.section).toContain("new file mode 100644"); + expect(patch.section).toContain("--- /dev/null"); + expect(patch.section).toContain("+++ b/DEMO.md"); + // Git points the range a new file does not have at line zero, not at line one. + expect(patch.section).toContain("@@ -0,0 +1 @@"); + expect(patch.section).toContain("+hello"); + }); + + it("names the side a deleted file no longer has as /dev/null", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: { oldContents: "gone\n", newContents: "" }, + }); + + expect(patch.section).toContain("deleted file mode 100644"); + expect(patch.section).toContain("--- a/OLD.md"); + expect(patch.section).toContain("+++ /dev/null"); + expect(patch.section).toContain("@@ -1 +0,0 @@"); + expect(patch.section).toContain("-gone"); + }); + + it("keeps the carriage returns of a file with Windows line endings", () => { + // They are part of the line rather than around it, so a patch that dropped them would ask + // the reader to look at a change that is not the one on the host. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\r\ntwo\r\n", newContents: "one\r\ntwo again\r\n" }, + }); + + expect(patch.section).toContain("-two\r"); + expect(patch.section).toContain("+two again\r"); + }); + + it("keeps a file that only moved, which has no hunks to give", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), + texts: { oldContents: "same\n", newContents: "same\n" }, + }); + + expect(patch.truncated).toBe(false); + expect(patch.section).toBe( + [ + "diff --git a/docs/old.md b/docs/new.md", + "rename from docs/old.md", + "rename to docs/new.md", + "--- a/docs/old.md", + "+++ b/docs/new.md", + "", + ].join("\n"), + ); + }); + + it("reports a binary file as changed rather than spelling it out", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: { oldContents: "PNG\u0000old", newContents: "PNG\u0000new" }, + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("shows an overlong file as changed without its hunks", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.js", oldPath: "bundle.js" }), + texts: { oldContents: "a\n".repeat(400_000), newContents: "b\n".repeat(400_000) }, + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"), + ); + }); + + it("marks a file that does not end in a newline, as git does", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: { oldContents: "one\n", newContents: "two" }, + }); + + expect(patch.section).toContain("\\ No newline at end of file"); + }); +}); + +describe("a diff cursor", () => { + it("carries the push it was taken against back to the next slice", () => { + const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); + + expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 }); + }); + + it("reads anything it did not write as no position at all", () => { + // Which starts the read from the top rather than failing it: a cursor is the client's to + // hand back, and nothing downstream is worth refusing a whole diff over. + for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { + expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); + } + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts new file mode 100644 index 000000000000..97d811c8b35c --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -0,0 +1,139 @@ +import { structuredPatch } from "diff"; + +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +/** + * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed + * files off an iteration, so the iteration travels with the position: a push landing mid-read + * would otherwise renumber the list under the cursor and hand the reader a file twice or not at + * all. + */ +export interface AzureDevOpsDiffCursor { + readonly iterationId: number; + readonly fileIndex: number; +} + +const CURSOR_SEPARATOR = ":"; + +export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { + return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; +} + +/** Null for anything this did not write, which starts the read from the top rather than failing. */ +export function parseAzureDevOpsDiffCursor( + raw: string | null | undefined, +): AzureDevOpsDiffCursor | null { + if (raw === null || raw === undefined) return null; + const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); + if (rest.length > 0) return null; + const iterationId = Number(iteration); + const fileIndex = Number(file); + if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; + if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; + return { iterationId, fileIndex }; +} + +/** The two texts of one changed file, empty on whichever side the change does not have. */ +export interface AzureDevOpsFileTexts { + readonly oldContents: string; + readonly newContents: string; +} + +export interface AzureDevOpsFilePatch { + readonly section: string; + /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ + readonly truncated: boolean; +} + +/** + * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather + * than a patch, so a generated bundle or a checked-in dump is paid for twice over before anything + * can be diffed, and nobody reads the result either way. + */ +const MAX_FILE_BYTES = 512 * 1024; + +/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ +const PATCH_CONTEXT_LINES = 3; + +/** + * How much patch one slice carries before the rest is left for the next one. Every file costs a + * request per side, so the read stops on what it has produced rather than on a file count: a + * hundred one-line changes are cheaper to finish than three long ones. + */ +export const MAX_DIFF_SLICE_BYTES = 256 * 1024; + +/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ +function isBinary(contents: string): boolean { + return contents.includes("\u0000"); +} + +/** + * Git points an empty range at the line before it, which is line zero for a file that is wholly + * new or wholly gone, and writes a single line as its number alone. + */ +function hunkRange(start: number, lines: number): string { + if (lines === 0) return `${start - 1},0`; + return lines === 1 ? String(start) : `${start},${lines}`; +} + +/** + * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file + * mode, so the ordinary one stands in, exactly as it does for the GitHub files API here. + */ +function patchHeader(change: AzureDevOpsChangeEntry): string { + const lines = [`diff --git a/${change.oldPath} b/${change.path}`]; + if (change.changeKind === "new") lines.push("new file mode 100644"); + if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); + if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { + lines.push(`rename from ${change.oldPath}`, `rename to ${change.path}`); + } + lines.push( + `--- ${change.changeKind === "new" ? "/dev/null" : `a/${change.oldPath}`}`, + `+++ ${change.changeKind === "deleted" ? "/dev/null" : `b/${change.path}`}`, + ); + return lines.join("\n"); +} + +/** + * One file's section of a unified patch, built here because Azure has no route that carries one: + * its diff routes name the files that changed and their blob ids, and the contents are a separate + * read per side. + */ +export function azureDevOpsFilePatch(input: { + readonly change: AzureDevOpsChangeEntry; + readonly texts: AzureDevOpsFileTexts; +}): AzureDevOpsFilePatch { + const header = patchHeader(input.change); + const { oldContents, newContents } = input.texts; + + if (isBinary(oldContents) || isBinary(newContents)) { + // Git's own wording for a file it will not spell out, which every diff viewer already reads. + const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; + return { section: `${header}\n${binary}\n`, truncated: true }; + } + if (oldContents.length > MAX_FILE_BYTES || newContents.length > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true }; + } + + const patch = structuredPatch( + `a/${input.change.oldPath}`, + `b/${input.change.path}`, + oldContents, + newContents, + undefined, + undefined, + { context: PATCH_CONTEXT_LINES }, + ); + const hunks = patch.hunks.map((hunk) => + [ + `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, + ...hunk.lines, + ].join("\n"), + ); + // A pure rename has no hunks to give. It is still listed, because dropping it would take the + // file out of the change altogether. + return { + section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, + truncated: false, + }; +} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index a975c89f858c..6c94e88e9584 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -2,6 +2,9 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, @@ -159,17 +162,15 @@ describe("decodePullRequestJson", () => { ); }); - it("works out where the conversation lives from what Azure returned", () => { + it("works out where the repository lives from what Azure returned", () => { const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); - expect(detail?.threadsUrl).toBe( - "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads", - ); + expect(detail?.location).toEqual({ project: "platform", repository: "web" }); }); - it("reports no conversation url when Azure said too little to build one", () => { - // A web link places the pull request, but without the REST url and repository there is - // nothing to hang a threads collection off. + it("reports no repository location when Azure said too little to name one", () => { + // A web link places the pull request, but with no repository named there is nothing to + // address the routes that read its files and its conversation. const detail = expectSuccess( decodePullRequestJson( asJson( @@ -184,7 +185,7 @@ describe("decodePullRequestJson", () => { ), ); - expect(detail?.threadsUrl).toBeNull(); + expect(detail?.location).toBeNull(); }); it("returns nothing when Azure gave no way to place the pull request at all", () => { @@ -313,3 +314,127 @@ describe("decodeThreadsJson", () => { expect(comments).toEqual([]); }); }); + +describe("decodeIterationsJson", () => { + const iteration = (id: number, head: string, base: string) => ({ + id, + sourceRefCommit: { commitId: head }, + commonRefCommit: { commitId: base }, + targetRefCommit: { commitId: base }, + }); + + it("reads every push in order, oldest first", () => { + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); + expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); + }); + + it("skips a push Azure could not place both ends of", () => { + // A patch is taken over a range, and an iteration missing either end names no range at all. + const iterations = expectSuccess( + decodeIterationsJson( + asJson({ + value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], + }), + ), + ); + + expect(iterations.map((entry) => entry.id)).toEqual([2]); + }); +}); + +describe("decodeIterationChangesJson", () => { + it("names each changed file without the slash Azure leads its paths with", () => { + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, + { + changeType: "edit", + item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, + }, + { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, + ], + }), + ), + ); + + expect(changes.map((change) => [change.path, change.changeKind])).toEqual([ + ["DEMO.md", "new"], + ["README.md", "change"], + ["OLD.md", "deleted"], + ]); + }); + + it("reads a rename as one file that moved, and says whether it also changed", () => { + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + sourceServerItem: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + { + changeType: "edit, rename", + sourceServerItem: "/src/old.ts", + item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, + }, + ], + }), + ), + ); + + expect(changes).toEqual([ + { + path: "docs/new.md", + oldPath: "docs/old.md", + changeKind: "rename-pure", + objectId: "aaaa", + originalObjectId: "aaaa", + }, + { + path: "src/new.ts", + oldPath: "src/old.ts", + changeKind: "rename-changed", + objectId: "bbbb", + originalObjectId: "cccc", + }, + ]); + }); + + it("drops the folders Azure lists alongside the files that changed", () => { + // A review shows files, and a folder has no content on either side to show for one. + const changes = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, + { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, + ], + }), + ), + ); + + expect(changes.map((change) => change.path)).toEqual(["docs/page.md"]); + }); +}); + +describe("decodeItemContentJson", () => { + it("reads the file's text out of the envelope Azure wraps it in", () => { + expect( + expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), + ).toBe("one\ntwo"); + }); + + it("reads an empty file as empty rather than as a failure to look", () => { + expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toBe(""); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 39ca4a551d27..bb01e578b4a3 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -11,10 +11,7 @@ import type { import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - azureDevOpsOrganizationBaseFromRestApiUrl, - azureDevOpsPullRequestWebUrl, -} from "../sourceControl/azureDevOpsPullRequests.ts"; +import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts"; /** * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as @@ -108,6 +105,16 @@ const RawViewerSchema = Schema.Struct({ ), }); +/** + * Where a repository lives, in the terms Azure's REST routes address it by. They take the project + * and the repository as separate route parameters rather than as one path, so the pair travels + * together rather than as a URL that would have to be taken apart again to use. + */ +export interface AzureDevOpsRepositoryLocation { + readonly project: string; + readonly repository: string; +} + export interface AzureDevOpsPullRequest { readonly number: number; readonly title: string; @@ -128,8 +135,8 @@ export interface AzureDevOpsPullRequest { readonly body: string; readonly reviewRequestLogins: ReadonlyArray; readonly reviewers: ReadonlyArray; - /** Where this pull request's threads live, when Azure said enough to work it out. */ - readonly threadsUrl: string | null; + /** Where this pull request lives, when Azure said enough to work it out. */ + readonly location: AzureDevOpsRepositoryLocation | null; /** Whether Azure is set to complete this on its own once its policies pass. */ readonly autoMergeEnabled: boolean; } @@ -177,15 +184,16 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili } /** - * The REST collection a pull request's threads hang from. Built from what Azure returned rather - * than from the local remote, whose shape differs between the modern, legacy and SSH forms. + * Where a pull request's own repository sits. Taken from what Azure returned rather than from the + * local remote, whose shape differs between the modern, legacy and SSH forms. */ -function toThreadsUrl(raw: Schema.Schema.Type): string | null { - const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); +function toLocation( + raw: Schema.Schema.Type, +): AzureDevOpsRepositoryLocation | null { const project = trimmed(raw.repository?.project?.name); const repository = trimmed(raw.repository?.name); - if (base === null || project === null || repository === null) return null; - return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; + if (project === null || repository === null) return null; + return { project, repository }; } /** @@ -230,7 +238,7 @@ function toPullRequest( body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, - threadsUrl: toThreadsUrl(raw), + location: toLocation(raw), autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, }; } @@ -340,3 +348,154 @@ export function decodeThreadsJson( comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), ); } + +/** + * One push's worth of a pull request. Azure records every push as an iteration and keys the whole + * review off them: the changed files, and the marks a reader leaves on those files, both hang + * from an iteration rather than from the pull request. + */ +const RawIterationSchema = Schema.Struct({ + id: Schema.Int, + sourceRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + commonRefCommit: Schema.optional( + Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); + +const RawChangeEntrySchema = Schema.Struct({ + changeType: Schema.optional(Schema.NullOr(Schema.String)), + sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), + item: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + objectId: Schema.optional(Schema.NullOr(Schema.String)), + originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), + /** Azure marks a directory this way; a review has nothing to show for one. */ + isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), + gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawChangePageSchema = Schema.Struct({ changeEntries: Schema.Array(Schema.Unknown) }); + +const RawItemContentSchema = Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** The head and the merge base of one iteration, which is the range its patch is taken over. */ +export interface AzureDevOpsIteration { + readonly id: number; + readonly headCommit: string; + readonly mergeBaseCommit: string; +} + +/** + * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which + * Azure reports by naming the file's previous home rather than as a delete and an add. + */ +export interface AzureDevOpsChangeEntry { + readonly path: string; + readonly oldPath: string; + readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; + readonly objectId: string | null; + readonly originalObjectId: string | null; +} + +const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); +const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); +const decodeChangePage = decodeJsonResult(RawChangePageSchema); +const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); +const decodeItemContent = decodeJsonResult(RawItemContentSchema); + +/** + * Azure leads a path with a slash, which is its own spelling rather than part of the name. Every + * other host, and every patch, names the same file without it. + */ +function toRepositoryPath(value: string | null | undefined): string | null { + const path = trimmed(value); + return path === null ? null : path.replace(/^\/+/, ""); +} + +/** + * Azure names a change with one word or two, and a rename arrives either alone or alongside the + * edit that came with it. Anything it has added since reads as a plain change, which shows the + * file rather than dropping it from the review. + */ +function toChangeKind( + raw: string | null | undefined, + renamed: boolean, +): AzureDevOpsChangeEntry["changeKind"] { + const parts = new Set( + (raw ?? "") + .toLowerCase() + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ); + if (parts.has("delete")) return "deleted"; + if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; + if (parts.has("add")) return "new"; + return "change"; +} + +export function decodeIterationsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeIterationPage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const iterations: AzureDevOpsIteration[] = []; + for (const entry of decoded.success.value) { + const decodedIteration = decodeIterationEntry(entry); + if (Exit.isFailure(decodedIteration)) continue; + const iteration = decodedIteration.value; + const headCommit = trimmed(iteration.sourceRefCommit?.commitId); + const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); + // An iteration Azure cannot place both ends of names no range, and a patch needs both. + if (headCommit === null || mergeBaseCommit === null) continue; + iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); + } + return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); +} + +export function decodeIterationChangesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeChangePage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const changes: AzureDevOpsChangeEntry[] = []; + for (const entry of decoded.success.changeEntries) { + const decodedChange = decodeChangeEntry(entry); + if (Exit.isFailure(decodedChange)) continue; + const change = decodedChange.value; + const path = toRepositoryPath(change.item?.path); + if (path === null) continue; + // Azure lists the folders a change touched alongside the files themselves. A review shows + // files, and a folder has no content to show for either side of one. + if (change.item?.isFolder === true) continue; + if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; + const oldPath = toRepositoryPath(change.sourceServerItem) ?? path; + changes.push({ + path, + oldPath, + changeKind: toChangeKind(change.changeType, oldPath !== path), + objectId: trimmed(change.item?.objectId), + originalObjectId: trimmed(change.item?.originalObjectId), + }); + } + return Result.succeed(changes); +} + +/** Azure answers an absent file with an empty body rather than an error, which reads as empty. */ +export function decodeItemContentJson(raw: string): Result.Result { + const decoded = decodeItemContent(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.content ?? "") + : Result.fail(decoded.failure); +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 7c266a19ca39..b346d2d31dbd 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -9,7 +9,7 @@ T3 Code works with the platforms your team already uses: - **GitHub** – Pull requests, repository creation, and clone integration - **GitLab** – Merge requests, repository publishing, and hosted clones - **Bitbucket** – Pull request workflows (via API token authentication) -- **Azure DevOps** – Pull request support for Microsoft-hosted repositories +- **Azure DevOps** – Pull request support for Microsoft-hosted repositories, including the file-by-file diff ## What You Can Do @@ -64,11 +64,12 @@ T3 Code works with the platforms your team already uses: again - On GitHub, the ticks are the ones GitHub keeps, so a review carries on between T3 Code and github.com in either direction -- On GitLab, they are kept by the T3 Code server you are connected to, because GitLab only - remembers them in one browser's own storage. They still follow you between the apps connected to - that server, but GitLab's own site will not show them. The count reads **viewed in T3 Code** so - you can tell at a glance, and an info icon beside it explains why -- Bitbucket and Azure DevOps do not keep this at all, so the checkbox is not shown there +- On GitLab, Bitbucket, and Azure DevOps, they are kept by the T3 Code server you are connected + to, because none of the three offers a record T3 Code can read: GitLab remembers it in one + browser's own storage, Bitbucket not at all, and Azure DevOps only inside its own web app. They + still follow you between the apps connected to that server, but the host's own site will not + show them. The count reads **viewed in T3 Code** so you can tell at a glance, and an info icon + beside it explains why - Scope the **Code** tab to a single commit and the checkboxes stay, so you can read a change one commit at a time. A tick belongs to the pull request, not to the commit, so a file you clear there is cleared everywhere diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8986f0f8586a..22068a133f26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) From bbf04bb840919a8f9e485d16bf2b5ca73f876f20 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 18:34:07 -0400 Subject: [PATCH 19/25] perf(server): a review's ticks stop waiting on the host The marks are this environment's own rows and cost nothing to read, but every read of them blocked on a host call that only the Changed badge needed, and a press paid for that call twice over. On Azure, where each one is a process spawn, coming back to a review left the checkboxes empty for seconds at a time. Correcting a badge a moment late is cheaper than making a reader wait for it, so a held answer now stands while the next one is fetched. The press itself still asks outright, since it stamps what it stores. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 73 +++++++++ .../AzureDevOpsPullRequestProvider.ts | 37 ++++- .../pullRequest/PullRequestService.test.ts | 84 ++++++++++- .../src/pullRequest/PullRequestService.ts | 140 ++++++++++++++++-- 4 files changed, 312 insertions(+), 22 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index d6da118936bc..d5ae39164548 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -553,6 +553,79 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads where a pull request lives once, however often it is asked about", () => + Effect.gen(function* () { + // A pull request cannot move repositories, and the marks would otherwise pay for a whole + // pull request read every time they checked whether a file had been pushed to. + const pullRequest = Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ); + const iterations = () => + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ); + const changes = () => + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + changeEntries: [ + { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, + ], + }), + ), + ); + mockedExecute + .mockReturnValueOnce(pullRequest) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()) + .mockReturnValueOnce(iterations()) + .mockReturnValueOnce(changes()); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const read = provider.getFileRevisions; + assert.isDefined(read); + const ask = () => + read({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["README.md"], + }); + + yield* ask(); + const again = yield* ask(); + + assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The second read goes straight to the pushes, and still answers with the head's blob. + expect(argsOfCall(3)).toContain("pullRequestIterations"); + expect([...again.revisions]).toEqual([["README.md", "8f80"]]); + }), + ); + it.effect("leaves out a marked file the pull request no longer changes", () => Effect.gen(function* () { // Which reads as the empty revision, the same thing stored for a file that had none when it diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 1fe7f60f66fb..58072f00159b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -143,15 +143,44 @@ export const make = Effect.gen(function* () { /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; + /** + * Where a pull request's repository lives, which is the route every other read of it needs and + * the one thing only the pull request itself states. A pull request cannot move between + * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for + * a whole pull request read every time they checked whether a file had been pushed to. + * + * Bounded and oldest-first, since a long-lived server sees far more pull requests than a reader + * ever has open. + */ + const LOCATION_CACHE_CAPACITY = 128; + const locations = new Map(); + + const locationOf = (input: { readonly cwd: string; readonly number: number }) => { + const key = `${input.cwd} ${input.number}`; + const held = locations.get(key); + if (held !== undefined) return Effect.succeed(held); + return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.map((pullRequest) => { + const location = pullRequest.location; + if (location === null) return null; + if (locations.size >= LOCATION_CACHE_CAPACITY) { + const oldest = locations.keys().next().value; + if (oldest !== undefined) locations.delete(oldest); + } + locations.set(key, location); + return location; + }), + ); + }; + /** * Everything a diff read needs before it can ask for a file: where the repository lives, and - * which pushes the pull request has had. A client names neither, and the pull request read is - * the only place Azure states the first. + * which pushes the pull request has had. A client names neither, and the iterations are read + * afresh every time because the newest one is what a push adds. */ const diffScope = (input: { readonly cwd: string; readonly number: number }) => Effect.gen(function* () { - const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); - const location = pullRequest.location; + const location = yield* locationOf(input); if (location === null) return null; const iterations = yield* cli.listIterations({ cwd: input.cwd, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 6882a9f6f563..970b9142c1d1 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3568,14 +3568,92 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () ], ); assert.strictEqual(marked.truncated, false); - // The marked paths alone, so the cost follows how much has been read rather than PR size. + // The marked paths alone, so the cost follows how much has been read rather than PR size, + // and the read after the press is answered from what the press already heard. assert.deepStrictEqual( asked.map((paths) => [...paths].toSorted()), + [["src/a.ts", "src/b.ts"]], + ); + }), +); + +it.effect("reads the marks without asking the host what the head has every time", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Past the marks' own cache, so this read reaches the point where the host would be asked. + yield* TestClock.adjust("20 seconds"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]); + assert.deepStrictEqual(asked, [["src/a.ts"]]); + }), +); + +it.effect("answers the marks from what it last heard while it asks the host again", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* TestClock.adjust("90 seconds"); + const held = yield* service.filesViewed(GITLAB_REFERENCE); + + // The push is not in this answer, because waiting for the host is the thing being avoided. + assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]); + assert.strictEqual(asked.length, 2); + + yield* TestClock.adjust("20 seconds"); + const caught = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); + // The refresh behind the previous answer is the one that heard about the push. + assert.strictEqual(asked.length, 2); + }), +); + +it.effect("asks the host about a file it has not been asked about before", () => + Effect.gen(function* () { + const asked: Array> = []; + const service = yield* environmentViewedService( + new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]), + asked, + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* TestClock.adjust("20 seconds"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + yield* TestClock.adjust("20 seconds"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - ["src/a.ts", "src/b.ts"], - ["src/a.ts", "src/b.ts"], + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, ], ); + // The second press paid for its own file; the read that follows was already covered. + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 5b4f0b0c79ae..e33988555361 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -113,6 +113,15 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); * all only so opening a change request on two devices costs one read. */ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); +/** + * How long the head's blob for a file is believed without asking the host again, and how long a + * held answer still stands while the next one is fetched. The marks themselves are this + * environment's own rows and cost nothing to read; this is the host call behind the **Changed** + * badge alone, so a held answer costs a badge that is a minute behind rather than a stale tick. + */ +const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); +const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); +const FILE_REVISIONS_CACHE_CAPACITY = 64; /** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ @@ -1302,6 +1311,10 @@ export const make = Effect.gen(function* () { }), ); + const context = yield* Effect.context(); + /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */ + const runFork = Effect.runForkWith(context); + /** * Which change request's marks, and whose. The host is part of it because the same * `owner/repo` exists on more than one install, and the reader is part of it for the reason @@ -1323,30 +1336,121 @@ export const make = Effect.gen(function* () { cause, }); + /** + * What the head has of the files a reader has marked, held between reads. A path that was asked + * for and is not in the answer is one the head does not carry, which the marks read as the empty + * revision — so the entry remembers what it has been asked rather than treating every miss as an + * answer it never had. + */ + interface HeldFileRevisions { + readonly at: number; + readonly asked: ReadonlySet; + readonly revisions: ReadonlyMap; + } + const heldFileRevisions = new Map(); + const refreshingFileRevisions = new Set(); + /** + * Normalised, because a reference reaches here spelled however the client spelled it while the + * project carries the remote's own spelling, and a refresh that missed by a capital would leave + * the held answer standing. + */ + const fileRevisionsScope = (projectId: string, repository: string, number: number) => + `${projectId} ${repository.trim().toLowerCase()} ${number}`; + + const recordFileRevisions = ( + scope: string, + paths: ReadonlyArray, + answer: ReadonlyMap, + ) => + Effect.map(Clock.currentTimeMillis, (at) => { + const held = heldFileRevisions.get(scope); + // Past the stale window the old entry is not worth merging into: it would carry paths + // nobody has asked about since, at revisions the head has long moved off. + const carried = + held !== undefined && at - held.at <= Duration.toMillis(FILE_REVISIONS_STALE_WINDOW) + ? held + : null; + const revisions = new Map(carried?.revisions ?? []); + const asked = new Set(carried?.asked ?? []); + for (const path of paths) { + asked.add(path); + const revision = answer.get(path); + if (revision === undefined) revisions.delete(path); + else revisions.set(path, revision); + } + heldFileRevisions.delete(scope); + if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { + const oldest = heldFileRevisions.keys().next().value; + if (oldest !== undefined) heldFileRevisions.delete(oldest); + } + heldFileRevisions.set(scope, { at, asked, revisions }); + return revisions; + }); + + /** A held entry that covers every path asked for and is still worth answering from. */ + const heldFileRevisionsFor = (scope: string, paths: ReadonlyArray, now: number) => { + const held = heldFileRevisions.get(scope); + if (held === undefined) return null; + if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null; + return paths.every((path) => held.asked.has(path)) ? held : null; + }; + + const forgetFileRevisions = (scope: string) => { + heldFileRevisions.delete(scope); + }; + /** * What the head has of these files, or null where the host cannot say. Null is not an error: * without it the marks simply stop reporting staleness, which is worse than the host's own * record but better than refusing to remember anything. + * + * `held` answers from a value past its lifetime and fetches the next one off the critical path, + * because a badge a moment behind beats a page of ticks that will not paint until a host answers. + * `fresh` is for the press itself, which stamps what it stores and would otherwise write a + * revision the head had already moved off. */ const fileRevisionsOf = ( project: SupportedProject, number: number, paths: ReadonlyArray, operation: string, + freshness: "held" | "fresh" = "held", ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; - return read === undefined - ? Effect.succeed(null) - : read({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number, - paths, - }).pipe( - Effect.map((answer) => answer.revisions), - Effect.mapError(toPullRequestError(operation)), + if (read === undefined) return Effect.succeed(null); + const scope = fileRevisionsScope(project.project.id, project.repository, number); + // Suspended, so a held answer costs the host nothing: a provider is free to do its work as + // the request is built rather than as the effect is run. + const fetch = Effect.suspend(() => + read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number, + paths, + }).pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions)), + ), + ); + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const held = heldFileRevisionsFor(scope, paths, now); + if (held === null) return fetch; + if (now - held.at <= Duration.toMillis(FILE_REVISIONS_CACHE_TTL)) + return Effect.succeed(held.revisions); + if (freshness === "fresh") return fetch; + if (refreshingFileRevisions.has(scope)) return Effect.succeed(held.revisions); + // Its own fiber rather than a child: the caller has been answered and is gone before this + // lands. One at a time per change request, so a page of files costs one host read. + return Effect.sync(() => { + refreshingFileRevisions.add(scope); + runFork( + Effect.ignore(fetch).pipe( + Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(scope))), + ), ); + }).pipe(Effect.as(held.revisions)); + }); }; /** @@ -1402,7 +1506,7 @@ export const make = Effect.gen(function* () { const revisions = cleared.length === 0 ? null - : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed"); + : yield* fileRevisionsOf(project, input.number, cleared, "setFilesViewed", "fresh"); const viewedAt = DateTime.formatIso(yield* DateTime.now); yield* filesViewedStore .set({ @@ -1995,9 +2099,6 @@ export const make = Effect.gen(function* () { return { stats: stats.flat() }; }); - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - /** * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. * Explicit refreshes and mutations still strand held values through the reference epoch. @@ -2279,9 +2380,18 @@ export const make = Effect.gen(function* () { // A whole-workspace refresh is the reader asking to be re-answered from the hosts, // and that includes who the hosts say they are. viewersByHost.clear(); + heldFileRevisions.clear(); return; } bumpRefEpoch(input.reference); + // Not keyed by epoch, so this one is dropped by hand rather than stranded. + forgetFileRevisions( + fileRevisionsScope( + input.reference.projectId, + input.reference.repository, + input.reference.number, + ), + ); }); // A mutation's own client re-reads right after it, and every other client's next read must From cd7ccccd14032d80d31520a30bc744e2cd198179 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:26:56 -0400 Subject: [PATCH 20/25] fix(server): a long or unreadable Azure change still renders its diff Azure pages its change list and answers for a binary file in an encoding of its own, so a large pull request came back as part of a change presented as the whole of it, and a file whose contents az would not hand over took the rest of the slice down with it. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 204 +++++++++++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 65 ++++-- .../AzureDevOpsPullRequestProvider.ts | 55 +++-- .../src/pullRequest/azureDevOpsDiff.test.ts | 56 ++++- .../server/src/pullRequest/azureDevOpsDiff.ts | 27 ++- .../azureDevOpsPullRequestJson.test.ts | 75 ++++++- .../pullRequest/azureDevOpsPullRequestJson.ts | 58 ++++- 7 files changed, 482 insertions(+), 58 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index d5ae39164548..e8a5472c45a2 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -29,6 +29,9 @@ function output(stdout: string) { }; } +/** A fixture's own shape, spelled the way `az` would answer with it. */ +const json = (value: Record) => JSON.stringify(value); + function pullRequestRows( count: number, firstNumber: number, @@ -575,7 +578,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const iterations = () => Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ value: [ { @@ -590,7 +592,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const changes = () => Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ changeEntries: [ { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, @@ -626,10 +627,203 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("leaves out a marked file the pull request no longer changes", () => + it.effect( + "answers for a marked file the pull request no longer changes as the empty version", + () => + Effect.gen(function* () { + // Which is what was stored for it when it was ticked with nothing on the head, so a file + // the pull request deletes is cleared once and stays cleared. + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["GONE.md"], + }); + + expect([...answer.revisions]).toEqual([["GONE.md", ""]]); + }), + ); + + it.effect("says nothing about the files past the end of a change it gave up following", () => + Effect.gen(function* () { + // Every page is an `az` process of its own, so a change past the ceiling stops being + // followed. A path nobody looked at must not be answered for as deleted. + const entries = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ + changeType: "edit", + item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` }, + })); + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))), + ) + .mockReturnValueOnce( + Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], + }); + + // The second page picks up where the first said it ended. + expect(argsOfCall(3)).toContain("$skip=5000"); + expect([...answer.revisions]).toEqual([ + ["src/f1.ts", "blob-1"], + ["src/f5001.ts", "blob-5001"], + ]); + }), + ); + + it.effect("stops following pages when one of them does not move the cursor on", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }], + nextSkip: 2_000, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }], + nextSkip: 2_000, + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["a.ts", "b.ts"], + }); + + // Four reads and no more: a page pointing at where it already is would be read forever. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + expect([...answer.revisions]).toEqual([ + ["a.ts", "8f80"], + ["b.ts", "0ca4"], + ]); + }), + ); + + it.effect("asks Azure nothing when no file has been ticked off", () => Effect.gen(function* () { - // Which reads as the empty revision, the same thing stored for a file that had none when it - // was ticked. A file the pull request deletes is cleared once and stays cleared. mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}'))); const provider = yield* AzureDevOpsPullRequestProvider.make; assert.isDefined(provider.getFileRevisions); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index a77bba0adf6b..7d6089c5a145 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -21,6 +21,7 @@ import { decodeThreadsJson, decodeViewerJson, type AzureDevOpsChangeEntry, + type AzureDevOpsItemContent, type AzureDevOpsIteration, type AzureDevOpsPullRequest, type AzureDevOpsRepositoryLocation, @@ -118,6 +119,23 @@ export type AzureDevOpsPullRequestCliError = /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; +/** Azure's own ceiling for one page of an iteration's changes. */ +const CHANGE_ENTRIES_PER_PAGE = 2000; + +/** + * Where following the pages stops. Every page is an `az` process of its own, and a change this + * long is past what any reader will get through, so the read gives up rather than spending a + * minute of spawns on it. Saying so is the point: the diff reports itself as incomplete instead + * of presenting five pages as the whole change. + */ +const MAX_CHANGE_ENTRIES = 10_000; + +/** What an iteration changed, and whether following its pages reached the end of it. */ +export interface AzureDevOpsIterationChanges { + readonly changes: ReadonlyArray; + readonly truncated: boolean; +} + export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, { @@ -178,7 +196,7 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly location: AzureDevOpsRepositoryLocation; readonly number: number; readonly iterationId: number; - }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + }) => Effect.Effect; /** * One file's text at one commit. Azure has no diff route that carries content, so both sides @@ -189,7 +207,7 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly location: AzureDevOpsRepositoryLocation; readonly path: string; readonly commit: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; @@ -548,17 +566,38 @@ export const make = Effect.gen(function* () { decode: decodeIterationsJson, }), - listIterationChanges: (input) => - invoke({ - cwd: input.cwd, - operation: "listIterationChanges", - resource: "pullRequestIterationChanges", - routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], - // Azure pages this route at 1000 entries by default. A review that large is already past - // what the client will render, and the ceiling is Azure's own maximum for the route. - queryParameters: ["$top=2000"], - decode: decodeIterationChangesJson, - }), + listIterationChanges: (input) => { + const page = (skip: number) => + invoke({ + cwd: input.cwd, + operation: "listIterationChanges", + resource: "pullRequestIterationChanges", + routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], + // Azure pages this route at 1000 entries by default; this is its own maximum per page, + // and it names where the next page starts rather than answering with the whole change. + queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], + decode: decodeIterationChangesJson, + }); + const from = ( + skip: number, + collected: ReadonlyArray, + ): Effect.Effect => + page(skip).pipe( + Effect.flatMap((answer) => { + const changes = [...collected, ...answer.changes]; + // A page that does not move the cursor on would be read forever, and a change this + // long is past anything a reader will get through — so the read stops and says so, + // rather than quietly presenting part of it as the whole. + if (answer.nextSkip === null || answer.nextSkip <= skip) { + return Effect.succeed({ changes, truncated: false }); + } + return changes.length >= MAX_CHANGE_ENTRIES + ? Effect.succeed({ changes, truncated: true }) + : from(answer.nextSkip, changes); + }), + ); + return from(0, []); + }, readItemContent: (input) => invoke({ diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 58072f00159b..034df758f1ba 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -4,6 +4,7 @@ import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3t import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, @@ -18,8 +19,10 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; +import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, + AzureDevOpsItemContent, AzureDevOpsIteration, AzureDevOpsPullRequest, AzureDevOpsRepositoryLocation, @@ -190,6 +193,8 @@ export const make = Effect.gen(function* () { return { location, iterations }; }); + const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -201,25 +206,31 @@ export const make = Effect.gen(function* () { readonly change: Pick; }) => Effect.gen(function* () { - const oldContents = + const oldItem = input.change.changeKind === "new" - ? "" + ? EMPTY_ITEM : yield* cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.oldPath, commit: input.iteration.mergeBaseCommit, }); - const newContents = + const newItem = input.change.changeKind === "deleted" - ? "" + ? EMPTY_ITEM : yield* cli.readItemContent({ cwd: input.cwd, location: input.location, path: input.change.path, commit: input.iteration.headCommit, }); - const texts: AzureDevOpsFileTexts = { oldContents, newContents }; + const texts: AzureDevOpsFileTexts = { + oldContents: oldItem.contents, + newContents: newItem.contents, + // Azure hands a file it calls binary over in an encoding of its own, so its own word on + // that is taken rather than looked for in bytes it may never have sent verbatim. + binary: oldItem.isBinary || newItem.isBinary, + }; return texts; }); @@ -236,7 +247,7 @@ export const make = Effect.gen(function* () { }) => { const latest = input.iterations.at(-1); return latest === undefined - ? Effect.succeed([] as ReadonlyArray) + ? Effect.succeed({ changes: [], truncated: false } as AzureDevOpsIterationChanges) : cli.listIterationChanges({ cwd: input.cwd, location: input.location, @@ -296,7 +307,7 @@ export const make = Effect.gen(function* () { iterations, }), ), - Effect.map((changes) => changes.length), + Effect.map((listed) => listed.changes.length), Effect.orElseSucceed(() => 0), ); const detail: ProviderChangeRequestDetail = { @@ -362,27 +373,34 @@ export const make = Effect.gen(function* () { ? scope.iterations.at(-1) : scope.iterations.find((candidate) => candidate.id === cursor.iterationId); if (iteration === undefined) return EMPTY_DIFF_SLICE; - const changes = yield* cli.listIterationChanges({ + const listed = yield* cli.listIterationChanges({ cwd: input.cwd, location: scope.location, number: input.number, iterationId: iteration.id, }); + const changes = listed.changes; const sections: string[] = []; - let truncated = false; + let truncated = listed.truncated; let bytes = 0; let index = cursor?.fileIndex ?? 0; while (index < changes.length) { const change = changes.at(index); if (change === undefined) break; + // One file per pair of reads, and a pair Azure refuses is one file rather than the + // whole slice: an oversize blob or a path `az` will not carry through leaves that file + // listed without its hunks, and everything around it still renders. const texts = yield* readTexts({ cwd: input.cwd, location: scope.location, iteration, change, - }); - const file = azureDevOpsFilePatch({ change, texts }); + }).pipe(Effect.orElseSucceed(() => null)); + const file = + texts === null + ? azureDevOpsUnreadableFilePatch(change) + : azureDevOpsFilePatch({ change, texts }); sections.push(file.section); bytes += file.section.length; truncated = truncated || file.truncated; @@ -425,8 +443,10 @@ export const make = Effect.gen(function* () { * it reports. One read covers every path: the latest iteration lists the whole change, so * asking per file would be the same answer fetched over and over. * - * A path the change no longer carries is left out rather than guessed at, which reads as the - * empty revision and leaves a file the pull request deletes cleared once and cleared for good. + * A path the change does not carry is at the empty revision, which is what a file the pull + * request deletes is at and leaves it cleared once and cleared for good. When the change was + * too long to follow to its end, those paths are left out instead: they were not looked at, + * and reporting them as deleted would clear a file nobody has read. */ getFileRevisions: (input) => Effect.gen(function* () { @@ -434,16 +454,21 @@ export const make = Effect.gen(function* () { if (input.paths.length === 0) return { revisions }; const scope = yield* diffScope(input); if (scope === null) return { revisions }; - const changes = yield* listLatestChanges({ + const listed = yield* listLatestChanges({ ...scope, cwd: input.cwd, number: input.number, }); const marked = new Set(input.paths); - for (const change of changes) { + for (const change of listed.changes) { if (!marked.has(change.path) || change.objectId === null) continue; revisions.set(change.path, change.objectId); } + if (!listed.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } return { revisions }; }).pipe(Effect.mapError(fail("getFileRevisions"))), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 1beba81e3660..585afdcb9d59 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, } from "./azureDevOpsDiff.ts"; @@ -18,11 +19,15 @@ function change(overrides: Partial = {}): AzureDevOpsCha }; } +function texts(oldContents: string, newContents: string, binary = false) { + return { oldContents, newContents, binary }; +} + describe("azureDevOpsFilePatch", () => { it("writes a changed file as the unified patch every diff viewer already reads", () => { const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\ntwo\nthree\n", newContents: "one\ntwo again\nthree\n" }, + texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"), }); expect(patch.truncated).toBe(false); @@ -44,7 +49,7 @@ describe("azureDevOpsFilePatch", () => { it("names the side a new file does not have as /dev/null", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), - texts: { oldContents: "", newContents: "hello\n" }, + texts: texts("", "hello\n"), }); expect(patch.section).toContain("new file mode 100644"); @@ -58,7 +63,7 @@ describe("azureDevOpsFilePatch", () => { it("names the side a deleted file no longer has as /dev/null", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), - texts: { oldContents: "gone\n", newContents: "" }, + texts: texts("gone\n", ""), }); expect(patch.section).toContain("deleted file mode 100644"); @@ -73,7 +78,7 @@ describe("azureDevOpsFilePatch", () => { // the reader to look at a change that is not the one on the host. const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\r\ntwo\r\n", newContents: "one\r\ntwo again\r\n" }, + texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), }); expect(patch.section).toContain("-two\r"); @@ -83,7 +88,7 @@ describe("azureDevOpsFilePatch", () => { it("keeps a file that only moved, which has no hunks to give", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }), - texts: { oldContents: "same\n", newContents: "same\n" }, + texts: texts("same\n", "same\n"), }); expect(patch.truncated).toBe(false); @@ -102,7 +107,7 @@ describe("azureDevOpsFilePatch", () => { it("reports a binary file as changed rather than spelling it out", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "logo.png", oldPath: "logo.png" }), - texts: { oldContents: "PNG\u0000old", newContents: "PNG\u0000new" }, + texts: texts("PNG\u0000old", "PNG\u0000new"), }); expect(patch.truncated).toBe(true); @@ -112,7 +117,7 @@ describe("azureDevOpsFilePatch", () => { it("shows an overlong file as changed without its hunks", () => { const patch = azureDevOpsFilePatch({ change: change({ path: "bundle.js", oldPath: "bundle.js" }), - texts: { oldContents: "a\n".repeat(400_000), newContents: "b\n".repeat(400_000) }, + texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)), }); expect(patch.truncated).toBe(true); @@ -121,16 +126,51 @@ describe("azureDevOpsFilePatch", () => { ); }); + it("takes the host's word that a file is binary, whatever its bytes look like", () => { + // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("b2xk", "bmV3", true), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ"); + }); + + it("counts an overlong file in bytes rather than in characters", () => { + // Three bytes each, so a ceiling counted in code units would let three times the size through. + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes.md", oldPath: "notes.md" }), + texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), + }); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"), + ); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), - texts: { oldContents: "one\n", newContents: "two" }, + texts: texts("one\n", "two"), }); expect(patch.section).toContain("\\ No newline at end of file"); }); }); +describe("azureDevOpsUnreadableFilePatch", () => { + it("keeps a file the host would not hand over, listed without its hunks", () => { + const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" })); + + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"), + ); + }); +}); + describe("a diff cursor", () => { it("carries the push it was taken against back to the next slice", () => { const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 }); diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 97d811c8b35c..e8855580cfc0 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -37,6 +37,11 @@ export function parseAzureDevOpsDiffCursor( export interface AzureDevOpsFileTexts { readonly oldContents: string; readonly newContents: string; + /** + * The host's own word on whether this is a file it will not spell out. Azure hands such a file + * over base64-encoded, so its bytes are not in the text to be looked for. + */ + readonly binary: boolean; } export interface AzureDevOpsFilePatch { @@ -67,6 +72,13 @@ function isBinary(contents: string): boolean { return contents.includes("\u0000"); } +/** + * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted + * in characters lets a file of three-byte glyphs through at three times the size meant to be let + * through. + */ +const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); + /** * Git points an empty range at the line before it, which is line zero for a file that is wholly * new or wholly gone, and writes a single line as its number alone. @@ -106,12 +118,12 @@ export function azureDevOpsFilePatch(input: { const header = patchHeader(input.change); const { oldContents, newContents } = input.texts; - if (isBinary(oldContents) || isBinary(newContents)) { + if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; return { section: `${header}\n${binary}\n`, truncated: true }; } - if (oldContents.length > MAX_FILE_BYTES || newContents.length > MAX_FILE_BYTES) { + if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true }; } @@ -137,3 +149,14 @@ export function azureDevOpsFilePatch(input: { truncated: false, }; } + +/** + * A file listed without its hunks, for when the host would not hand one of its two sides over. + * The change still belongs in the patch: leaving it out would take the file out of the review + * altogether, and the reader would have no sign anything was missing. + */ +export function azureDevOpsUnreadableFilePatch( + change: AzureDevOpsChangeEntry, +): AzureDevOpsFilePatch { + return { section: `${patchHeader(change)}\n`, truncated: true }; +} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 6c94e88e9584..c0ce0406bc93 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -350,7 +350,7 @@ describe("decodeIterationsJson", () => { describe("decodeIterationChangesJson", () => { it("names each changed file without the slash Azure leads its paths with", () => { - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -365,7 +365,7 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes.map((change) => [change.path, change.changeKind])).toEqual([ + expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ ["DEMO.md", "new"], ["README.md", "change"], ["OLD.md", "deleted"], @@ -373,7 +373,7 @@ describe("decodeIterationChangesJson", () => { }); it("reads a rename as one file that moved, and says whether it also changed", () => { - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -392,7 +392,7 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes).toEqual([ + expect(page.changes).toEqual([ { path: "docs/new.md", oldPath: "docs/old.md", @@ -412,7 +412,7 @@ describe("decodeIterationChangesJson", () => { it("drops the folders Azure lists alongside the files that changed", () => { // A review shows files, and a folder has no content on either side to show for one. - const changes = expectSuccess( + const page = expectSuccess( decodeIterationChangesJson( asJson({ changeEntries: [ @@ -423,7 +423,52 @@ describe("decodeIterationChangesJson", () => { ), ); - expect(changes.map((change) => change.path)).toEqual(["docs/page.md"]); + expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); + }); + + it("carries where the next page of a long change starts", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + nextSkip: 2000, + }), + ), + ); + + expect(page.nextSkip).toBe(2000); + }); + + it("reads the last page, which names no page after it, as the end of the change", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + }); + + it("reads where a rename came from out of either of the two places Azure names it", () => { + // The iteration-changes route answers with `originalPath`; the commit routes answer with + // `sourceServerItem`, and both are the same fact under two names. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + originalPath: "/docs/old.md", + item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); }); }); @@ -431,10 +476,24 @@ describe("decodeItemContentJson", () => { it("reads the file's text out of the envelope Azure wraps it in", () => { expect( expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), - ).toBe("one\ntwo"); + ).toEqual({ contents: "one\ntwo", isBinary: false }); }); it("reads an empty file as empty rather than as a failure to look", () => { - expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toBe(""); + expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ + contents: "", + isBinary: false, + }); + }); + + it("keeps Azure's own word that a file is binary", () => { + // Which it answers base64-encoded, so nothing in the text it sent would give it away. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), + ), + ), + ).toEqual({ contents: "b2xk", isBinary: true }); }); }); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index bb01e578b4a3..0c7db28a8a18 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -369,6 +369,8 @@ const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknow const RawChangeEntrySchema = Schema.Struct({ changeType: Schema.optional(Schema.NullOr(Schema.String)), sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), + /** Where a renamed file came from. Azure states it here on an iteration's changes. */ + originalPath: Schema.optional(Schema.NullOr(Schema.String)), item: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -383,10 +385,18 @@ const RawChangeEntrySchema = Schema.Struct({ ), }); -const RawChangePageSchema = Schema.Struct({ changeEntries: Schema.Array(Schema.Unknown) }); +const RawChangePageSchema = Schema.Struct({ + changeEntries: Schema.Array(Schema.Unknown), + /** Where the page after this one starts. Azure leaves it out on the last page. */ + nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), +}); const RawItemContentSchema = Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)), + /** What Azure makes of the file it is handing over, which is where it says it is not text. */ + contentMetadata: Schema.optional( + Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), + ), }); /** The head and the merge base of one iteration, which is the range its patch is taken over. */ @@ -408,6 +418,22 @@ export interface AzureDevOpsChangeEntry { readonly originalObjectId: string | null; } +/** + * One page of what an iteration changed, and where the next one starts. Azure pages this route + * rather than answering with the whole change, so a review large enough to be paged is followed + * to its end instead of being cut off at the first page's worth. + */ +export interface AzureDevOpsChangePage { + readonly changes: ReadonlyArray; + readonly nextSkip: number | null; +} + +/** One file's text at one commit, and whether Azure says the text is text at all. */ +export interface AzureDevOpsItemContent { + readonly contents: string; + readonly isBinary: boolean; +} + const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); const decodeChangePage = decodeJsonResult(RawChangePageSchema); @@ -466,7 +492,7 @@ export function decodeIterationsJson( export function decodeIterationChangesJson( raw: string, -): Result.Result, DecodeFailure> { +): Result.Result { const decoded = decodeChangePage(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const changes: AzureDevOpsChangeEntry[] = []; @@ -480,7 +506,10 @@ export function decodeIterationChangesJson( // files, and a folder has no content to show for either side of one. if (change.item?.isFolder === true) continue; if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; - const oldPath = toRepositoryPath(change.sourceServerItem) ?? path; + // Azure names where a renamed file came from in either of two places depending on the route + // and the version, so both are read and the current path stands in when neither is there. + const oldPath = + toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; changes.push({ path, oldPath, @@ -489,13 +518,28 @@ export function decodeIterationChangesJson( originalObjectId: trimmed(change.item?.originalObjectId), }); } - return Result.succeed(changes); + const nextSkip = decoded.success.nextSkip ?? null; + return Result.succeed({ + changes, + nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, + }); } -/** Azure answers an absent file with an empty body rather than an error, which reads as empty. */ -export function decodeItemContentJson(raw: string): Result.Result { +/** + * Azure answers an absent file with an empty body rather than an error, which reads as empty. + * + * Whether the bytes are text is Azure's to say and not this decoder's to guess: a file it calls + * binary is reported as such however innocent its first bytes look, since Azure hands the body + * over in an encoding of its own choosing rather than verbatim. + */ +export function decodeItemContentJson( + raw: string, +): Result.Result { const decoded = decodeItemContent(raw); return Result.isSuccess(decoded) - ? Result.succeed(decoded.success.content ?? "") + ? Result.succeed({ + contents: decoded.success.content ?? "", + isBinary: decoded.success.contentMetadata?.isBinary === true, + }) : Result.fail(decoded.failure); } From 75c0bb8760516b3be26477757c85dfd6834eafbc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:27:02 -0400 Subject: [PATCH 21/25] fix(server): a review's ticks survive what the host could not read A host that answers for part of a change said nothing about the rest, and that silence was read as deletion, so every file past the cut was cleared over a version nobody ever looked at. Two presses on one file could also finish in the other order, and two Azure repositories of the same name shared one row of ticks. Signed-off-by: Yordis Prieto --- .../BitbucketPullRequestApi.test.ts | 37 ++-- .../pullRequest/BitbucketPullRequestApi.ts | 44 ++--- .../pullRequest/GitLabPullRequestCli.test.ts | 11 +- .../src/pullRequest/GitLabPullRequestCli.ts | 11 +- .../src/pullRequest/PullRequestProvider.ts | 8 +- .../pullRequest/PullRequestService.test.ts | 169 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 115 ++++++++++-- 7 files changed, 317 insertions(+), 78 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 7cdec6dc9a92..52ee64f4c6a9 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -395,36 +395,39 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["a.ts", "missing.ts"], }); - // `b.ts` is in the patch and was not asked about, and `missing.ts` was asked about and is - // not in the patch. Neither belongs in the answer. - assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); + // `b.ts` is in the patch and was not asked about, so it does not belong in the answer. + // `missing.ts` was asked about and the whole patch was read without finding it, which is + // what a file this pull request deletes looks like, so it is answered as the empty version. + assert.deepStrictEqual( + [...revisions], + [ + ["a.ts", "2222222"], + ["missing.ts", ""], + ], + ); expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); }), ); - it.effect("re-reads the patch once for a burst of presses rather than once per press", () => + it.effect("says nothing about the files past the end of a patch it could not read whole", () => Effect.gen(function* () { + // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. + // Answering for it as deleted would clear a mark on it once and for good. mockedRequest.mockReturnValueOnce( - Effect.succeed( - response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), - ), + Effect.succeed({ + body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", + truncated: true, + }), ); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - const first = yield* api.getFileRevisions({ - repository: "acme/web", - number: 72, - paths: ["a.ts"], - }); - const second = yield* api.getFileRevisions({ + const revisions = yield* api.getFileRevisions({ repository: "acme/web", number: 72, - paths: ["a.ts"], + paths: ["a.ts", "past-the-cut.ts"], }); - assert.deepStrictEqual([...first], [["a.ts", "2222222"]]); - assert.deepStrictEqual([...second], [["a.ts", "2222222"]]); - assert.strictEqual(mockedRequest.mock.calls.length, 1); + assert.deepStrictEqual([...revisions], [["a.ts", "2222222"]]); }), ); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index b841210b6a6f..bd653bf58bb2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,8 +1,5 @@ -import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -139,15 +136,6 @@ const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; /** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; -/** - * How long the versions read out of a patch stand for. The same window the diff itself is held - * for, deliberately: the patch on screen and what it is said to be at must not disagree, and a - * reader ticking their way down a file list would otherwise re-read the whole patch per press. - */ -const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); -/** Pull requests held at once, which is more than anyone has open. */ -const FILE_REVISIONS_CACHE_CAPACITY = 32; - export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -197,9 +185,12 @@ export class BitbucketPullRequestApi extends Context.Service< /** * What the pull request's head has of each of these paths, as opaque ids. * - * Read off the pull request's own patch, the only place Bitbucket states a file's version, and - * held briefly so that ticking files off does not re-read it per press. Paths the patch says - * nothing about are left out. + * Read off the pull request's own patch, the only place Bitbucket states a file's version. A + * path the patch does not carry is answered as the empty revision, and left out altogether + * when the patch was cut short at the byte ceiling and so cannot be spoken for. + * + * Held by the caller rather than here: the marks and the badge they feed share one window, + * and a second one underneath it would keep answering after a refresh had asked it not to. */ readonly getFileRevisions: (input: { readonly repository: string; @@ -577,19 +568,6 @@ export const make = Effect.gen(function* () { ), ); - const fileRevisionsCache = yield* Cache.makeWith( - (key: string) => { - const [repository, number] = JSON.parse(key) as [string, number]; - return pullRequestDiff({ repository, number }).pipe( - Effect.map((diff) => parseDiffFileRevisions(diff.patch)), - ); - }, - { - capacity: FILE_REVISIONS_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? FILE_REVISIONS_CACHE_TTL : Duration.zero), - }, - ); - return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -666,14 +644,20 @@ export const make = Effect.gen(function* () { getFileRevisions: (input) => input.paths.length === 0 ? Effect.succeed(new Map()) - : Cache.get(fileRevisionsCache, JSON.stringify([input.repository, input.number])).pipe( - Effect.map((all) => { + : pullRequestDiff({ repository: input.repository, number: input.number }).pipe( + Effect.map((diff) => { + const all = parseDiffFileRevisions(diff.patch); // Narrowed to what was asked for rather than handed back whole: the caller compares // the paths it named, and a patch of a thousand files has no business in its answer. + // + // A patch cut short at the byte ceiling says nothing about the files past the cut, + // so those paths are left out rather than reported as removed: the caller reads an + // absent path as one it could not learn about, and a mark on it is left alone. const asked = new Map(); for (const path of input.paths) { const revision = all.get(path); if (revision !== undefined) asked.set(path, revision); + else if (!diff.truncated) asked.set(path, ""); } return asked; }), diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 64c1e6af5aa3..2460d2e1ccb5 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1442,9 +1442,12 @@ layer("GitLabPullRequestCli.layer", (it) => { paths: ["src/a.ts", "src/gone.ts"], }); - // A path the head does not have is absent rather than empty, which is the answer for a - // file the merge request deletes. - expect([...revisions]).toEqual([["src/a.ts", "aaa"]]); + // Every path was looked for at the head, so one the head does not have is one the merge + // request removed: said as the empty version, which a mark on it was stamped with too. + expect([...revisions]).toEqual([ + ["src/a.ts", "aaa"], + ["src/gone.ts", ""], + ]); // The head the reader is looking at, not whatever the source branch has moved on to. // @effect-diagnostics-next-line preferSchemaOverJson:off const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); @@ -1491,13 +1494,11 @@ layer("GitLabPullRequestCli.layer", (it) => { ), ); mockedExecute.mockImplementation((request) => { - // @effect-diagnostics-next-line preferSchemaOverJson:off const body = JSON.parse(request.stdin ?? "{}") as { readonly variables: { readonly paths: ReadonlyArray }; }; return Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ data: { project: { diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17da291425fd..87fcd33482b6 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1100,7 +1100,16 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); }), - Effect.map((pages) => new Map(pages.flatMap((page) => [...page]))), + Effect.map((pages) => { + const revisions = new Map(pages.flatMap((page) => [...page])); + // Every path was looked for at the head, so one that is not there is one the merge + // request removed rather than one this could not read. Said as the empty revision, + // which is an answer the caller can compare against and keep. + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + return revisions as ReadonlyMap; + }), ); const viewerUsername = (input: { readonly cwd: string }) => diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index bb5d2e135e23..16f1e4c41ef8 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -212,8 +212,12 @@ export interface ProviderFilesViewed { * What version each of the asked-for files is at, on the change request's head. * * Opaque strings: the caller only ever compares one against another, and every host names a - * version its own way. A path the host answered nothing for is absent, which is the answer for a - * file the change request deletes rather than a failure to look. + * version its own way. The empty string is an answer rather than a gap — it is what a file the + * change request deletes is at, and a mark taken against it stays cleared. + * + * A path is absent only when the read could not say: a host that answered for part of the change + * must leave the rest out rather than report it as deleted, or a file past the cut would be + * cleared once and cleared for good. */ export interface ProviderFileRevisions { readonly revisions: ReadonlyMap; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 970b9142c1d1..13aaf25f8210 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,5 +1,7 @@ import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; import type { @@ -3484,6 +3486,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => const environmentViewedProvider = ( revisions: Map, asked: Array>, + unreadable: ReadonlySet = new Set(), ) => fakeProvider("gitlab", { capabilities: { @@ -3502,11 +3505,12 @@ const environmentViewedProvider = ( getFileRevisions: (input) => { asked.push(input.paths); return Effect.succeed({ + // A path the host looked at and did not find is at the empty version, which is what a + // file the change request deletes is at. One it could not look at is left out entirely. revisions: new Map( - input.paths.flatMap((path) => { - const revision = revisions.get(path); - return revision === undefined ? [] : [[path, revision] as const]; - }), + input.paths.flatMap((path) => + unreadable.has(path) ? [] : [[path, revisions.get(path) ?? ""] as const], + ), ), }); }, @@ -3515,6 +3519,7 @@ const environmentViewedProvider = ( const environmentViewedService = ( revisions: Map, asked: Array>, + unreadable: ReadonlySet = new Set(), ) => makeService({ projects: [ @@ -3526,7 +3531,7 @@ const environmentViewedService = ( provider: "gitlab", }), ], - providers: [environmentViewedProvider(revisions, asked)], + providers: [environmentViewedProvider(revisions, asked, unreadable)], }); const GITLAB_REFERENCE = { @@ -3723,6 +3728,160 @@ it.effect("keeps a deleted file cleared, which the head has no version of at all }), ); +it.effect("leaves a mark alone when the host could not say what the head has of it", () => + Effect.gen(function* () { + // A host answers for as much of a long change as it can read in one go. Reading the rest as + // deleted would clear every file past the cut over a version nobody ever looked at. + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/past-the-cut.ts", "blob-b"], + ]); + const service = yield* environmentViewedService( + revisions, + [], + new Set(["src/past-the-cut.ts"]), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/past-the-cut.ts", viewed: true }, + ], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/past-the-cut.ts", state: "viewed" }, + ], + ); + }), +); + +it.effect("finishes two presses on one file in the order they were made", () => + Effect.gen(function* () { + // A tick asks the host what it has of the file before it stores anything, and an untick asks + // nothing at all, so the second press would otherwise land first and be overwritten by the + // first one finishing behind it. + const held = yield* Deferred.make(); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of its own"), + setFilesViewed: () => Effect.die("the host keeps no marks of its own"), + getFileRevisions: (input) => + Deferred.await(held).pipe( + Effect.as({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), + ), + }), + ], + }); + + const tick = service + .setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }) + .pipe(Effect.runFork); + // Far enough for the tick to be waiting on the host rather than still on its way there. + yield* TestClock.adjust("1 second"); + const untick = service + .setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: false }], + }) + .pipe(Effect.runFork); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(held, undefined); + yield* Fiber.join(tick); + yield* Fiber.join(untick); + + // The untick came second and stands: the file is open again. + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual(marked.files, []); + }), +); + +it.effect("keeps the marks of two Azure repositories of the same name apart", () => + Effect.gen(function* () { + // Azure addresses a repository by its bare name, which is unique inside one of its projects + // and not across an organisation. Two `web` repositories would otherwise share one row. + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "platform web", + workspaceRoot: "/a", + repository: "acme/platform/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + project({ + id: "p2", + title: "other web", + workspaceRoot: "/b", + repository: "acme/other/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "environment", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"), + getFileRevisions: (input) => + Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }), + }), + ], + }); + const platform = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; + const other = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; + + yield* service.setFilesViewed({ ...platform, files: [{ path: "src/a.ts", viewed: true }] }); + + assert.deepStrictEqual((yield* service.filesViewed(platform)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(other)).files, []); + }), +); + it.effect("keeps environment marks apart from another change request's", () => Effect.gen(function* () { const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e33988555361..a1cf959604f3 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -6,6 +6,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -1315,6 +1316,20 @@ export const make = Effect.gen(function* () { /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */ const runFork = Effect.runForkWith(context); + /** + * Which repository a row belongs to, spelled widely enough that two of them are two rows. + * + * A provider's own selector is what the reads are addressed by, and Azure's is the bare + * repository name: unique inside one project and not across an organisation, so `api` in two + * projects would otherwise share one row and show each other's ticks. The remote already + * carries the whole path, so the marks are keyed by that instead. + */ + const filesViewedRepositoryOf = (project: SupportedProject) => { + if (project.api.kind !== "azure-devops") return project.repository; + const path = project.project.repositoryIdentity?.displayName?.trim(); + return path === undefined || path.length === 0 ? project.repository : path; + }; + /** * Which change request's marks, and whose. The host is part of it because the same * `owner/repo` exists on more than one install, and the reader is part of it for the reason @@ -1324,7 +1339,7 @@ export const make = Effect.gen(function* () { const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ provider: project.api.kind, host: project.host, - repository: project.repository, + repository: filesViewedRepositoryOf(project), number, viewer: viewer ?? "", }); @@ -1349,6 +1364,14 @@ export const make = Effect.gen(function* () { } const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); + /** + * Moved every time a held answer is dropped. A refresh already in flight when that happens + * still answers its own caller, and its answer is simply not kept: it was taken against a head + * the reader has since asked to stop believing, and keeping it would put the dropped entry + * straight back. One counter for every scope rather than one each, so an unrelated refresh + * costs an in-flight read its place in the cache and nothing else. + */ + let fileRevisionsGeneration = 0; /** * Normalised, because a reference reaches here spelled however the client spelled it while the * project carries the remote's own spelling, and a refresh that missed by a capital would leave @@ -1361,8 +1384,12 @@ export const make = Effect.gen(function* () { scope: string, paths: ReadonlyArray, answer: ReadonlyMap, + generation: number, ) => Effect.map(Clock.currentTimeMillis, (at) => { + // Answered from, never stored: the caller asked for this and it is as fresh as anything + // could be, but the scope it belongs to has been dropped since the read began. + if (generation !== fileRevisionsGeneration) return answer; const held = heldFileRevisions.get(scope); // Past the stale window the old entry is not worth merging into: it would carry paths // nobody has asked about since, at revisions the head has long moved off. @@ -1397,6 +1424,12 @@ export const make = Effect.gen(function* () { const forgetFileRevisions = (scope: string) => { heldFileRevisions.delete(scope); + fileRevisionsGeneration += 1; + }; + + const forgetEveryFileRevision = () => { + heldFileRevisions.clear(); + fileRevisionsGeneration += 1; }; /** @@ -1421,8 +1454,9 @@ export const make = Effect.gen(function* () { const scope = fileRevisionsScope(project.project.id, project.repository, number); // Suspended, so a held answer costs the host nothing: a provider is free to do its work as // the request is built rather than as the effect is run. - const fetch = Effect.suspend(() => - read({ + const fetch = Effect.suspend(() => { + const generation = fileRevisionsGeneration; + return read({ cwd: project.project.workspaceRoot, repository: project.repository, host: project.host, @@ -1430,9 +1464,9 @@ export const make = Effect.gen(function* () { paths, }).pipe( Effect.mapError(toPullRequestError(operation)), - Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions)), - ), - ); + Effect.flatMap((answer) => recordFileRevisions(scope, paths, answer.revisions, generation)), + ); + }); return Effect.flatMap(Clock.currentTimeMillis, (now) => { const held = heldFileRevisionsFor(scope, paths, now); if (held === null) return fetch; @@ -1479,21 +1513,62 @@ export const make = Effect.gen(function* () { "filesViewed", ); return { - files: marks.map((mark) => ({ - path: mark.path, - // Absent reads as the empty revision on both sides, so a file the change request - // deletes is cleared once and stays cleared rather than reporting itself changed the - // moment it is ticked. - state: - revisions === null || (revisions.get(mark.path) ?? "") === mark.revision - ? ("viewed" as const) - : ("dismissed" as const), - })), + files: marks.map((mark) => { + // A path the host had no answer for is one it could not look at, not one it looked at + // and found nothing: a read that saw part of a large change must not report the rest + // as changed against a revision nobody read. A file the change request deletes is + // answered as the empty revision, which is what its mark was stamped with, so it is + // cleared once and stays cleared. + const revision = revisions?.get(mark.path); + return { + path: mark.path, + state: + revision === undefined || revision === mark.revision + ? ("viewed" as const) + : ("dismissed" as const), + }; + }), // Every mark is a row this environment holds, so there is no page to run out of. truncated: false, }; }); + /** + * One environment-backed write at a time per change request. A tick asks the host what it has + * of the file before it stores anything and an untick asks nothing at all, so two presses in + * quick succession would otherwise finish in the other order and leave the tick's row standing + * over the untick that came after it. + */ + const filesViewedGates = new Map< + string, + { readonly gate: Semaphore.Semaphore; pending: number } + >(); + + const inFilesViewedOrder = ( + project: SupportedProject, + number: number, + write: Effect.Effect, + ) => + Effect.gen(function* () { + const key = `${project.project.id} ${filesViewedRepositoryOf(project).trim().toLowerCase()} ${number}`; + const held = filesViewedGates.get(key); + const entry = held ?? { gate: yield* Semaphore.make(1), pending: 0 }; + if (held === undefined) filesViewedGates.set(key, entry); + entry.pending += 1; + // Dropped once nobody is queued behind it, so a long-lived server does not keep a gate per + // change request anyone has ever ticked a file in. + return yield* entry.gate + .withPermits(1)(write) + .pipe( + Effect.ensuring( + Effect.sync(() => { + entry.pending -= 1; + if (entry.pending === 0) filesViewedGates.delete(key); + }), + ), + ); + }); + const environmentSetFilesViewed = ( project: SupportedProject, input: PullRequestSetFilesViewedInput, @@ -1559,7 +1634,11 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); } if (project.api.capabilities.viewedFiles === "environment") { - return environmentSetFilesViewed(project, input); + return inFilesViewedOrder( + project, + input.number, + environmentSetFilesViewed(project, input), + ); } return Effect.fail( new PullRequestOperationError({ @@ -2380,7 +2459,7 @@ export const make = Effect.gen(function* () { // A whole-workspace refresh is the reader asking to be re-answered from the hosts, // and that includes who the hosts say they are. viewersByHost.clear(); - heldFileRevisions.clear(); + forgetEveryFileRevision(); return; } bumpRefEpoch(input.reference); From a95bf642407bec835a47209129966f81cfa7e2e3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:40:41 -0400 Subject: [PATCH 22/25] fix(server): a part-read Azure change no longer passes as the whole of it A page Azure names but the read stops at was reported as the end of the change, so files it never listed read as removed from the pull request. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 138 +++++++++++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 23 ++- .../server/src/pullRequest/azureDevOpsDiff.ts | 14 +- 3 files changed, 165 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index e8a5472c45a2..16bea79972ac 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -748,6 +748,140 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("stops following pages by what Azure counts, not by what survives decoding", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted + // in files would never be reached however long the walk went on. + .mockImplementation((command) => { + const skip = command.args.find((arg) => arg.startsWith("$skip=")); + const from = Number(skip?.slice("$skip=".length) ?? 0); + return Effect.succeed( + output( + json({ + changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }], + nextSkip: from + 2_000, + }), + ), + ); + }); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.getFileRevisions); + + const answer = yield* provider.getFileRevisions({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + paths: ["src/page.ts"], + }); + + // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset + // rather than spending an `az` process a page for as long as Azure keeps paging. + assert.strictEqual(mockedExecute.mock.calls.length, 7); + // And it read part of a change, so it says nothing about the file it never saw. + assert.strictEqual(answer.revisions.size, 0); + }), + ); + + it.effect("takes Azure's own word on a file it will not spell out", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + // Azure leaves the metadata out unless it is asked for, and without it every file reads as + // text however it was stored. + expect(argsOfCall(3)).toContain("includeContentMetadata=true"); + expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); + assert.isTrue(slice.truncated); + }), + ); + it.effect("stops following pages when one of them does not move the cursor on", () => Effect.gen(function* () { mockedExecute @@ -810,11 +944,13 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { repository: "web", host: "dev.azure.com", number: 42, - paths: ["a.ts", "b.ts"], + paths: ["a.ts", "b.ts", "unlisted.ts"], }); // Four reads and no more: a page pointing at where it already is would be read forever. assert.strictEqual(mockedExecute.mock.calls.length, 4); + // And what was read is not the whole change, so the file nobody listed is left unanswered + // rather than reported as gone from the change request. expect([...answer.revisions]).toEqual([ ["a.ts", "8f80"], ["b.ts", "0ca4"], diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 7d6089c5a145..9f56b965a4a9 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -123,10 +123,14 @@ const REST_API_VERSION = "7.1"; const CHANGE_ENTRIES_PER_PAGE = 2000; /** - * Where following the pages stops. Every page is an `az` process of its own, and a change this + * Where following the pages stops, counted in the entries Azure was asked to skip rather than in + * the files that survived decoding. Every page is an `az` process of its own, and a change this * long is past what any reader will get through, so the read gives up rather than spending a * minute of spawns on it. Saying so is the point: the diff reports itself as incomplete instead * of presenting five pages as the whole change. + * + * Azure's own count is what bounds this, because a page can be entirely folders and other entries + * a review has nothing to show for. Bounding on what was kept would follow such a change forever. */ const MAX_CHANGE_ENTRIES = 10_000; @@ -585,13 +589,12 @@ export const make = Effect.gen(function* () { page(skip).pipe( Effect.flatMap((answer) => { const changes = [...collected, ...answer.changes]; - // A page that does not move the cursor on would be read forever, and a change this - // long is past anything a reader will get through — so the read stops and says so, - // rather than quietly presenting part of it as the whole. - if (answer.nextSkip === null || answer.nextSkip <= skip) { - return Effect.succeed({ changes, truncated: false }); - } - return changes.length >= MAX_CHANGE_ENTRIES + // The last page names no page after it, and only that is the end of the change. + if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); + // A page pointing at where the read already is would be followed forever, and one + // past the ceiling is a change nobody will read to the end of. Both stop the read + // and both say so, rather than presenting part of a change as the whole of it. + return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES ? Effect.succeed({ changes, truncated: true }) : from(answer.nextSkip, changes); }), @@ -610,6 +613,10 @@ export const make = Effect.gen(function* () { "versionDescriptor.versionType=commit", `versionDescriptor.version=${input.commit}`, "includeContent=true", + // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own + // word on whether the file is binary — which is the only reliable one, since a binary + // file arrives encoded rather than as the bytes it is on the host. + "includeContentMetadata=true", // Without this Azure answers with the file's own bytes rather than with a JSON // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. "$format=json", diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index e8855580cfc0..39d7e6e52345 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -60,6 +60,14 @@ const MAX_FILE_BYTES = 512 * 1024; /** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ const PATCH_CONTEXT_LINES = 3; +/** + * How long one file may be diffed for. The line diff costs the product of the two sides, so a pair + * of files under the size ceiling that share almost nothing can still hold the whole server for a + * long time. Past this the file is listed without its hunks, which is what the size ceiling already + * does and what the reader is already shown a sign of. + */ +const MAX_FILE_DIFF_MILLIS = 2_000; + /** * How much patch one slice carries before the rest is left for the next one. Every file costs a * request per side, so the read stops on what it has produced rather than on a file count: a @@ -134,8 +142,12 @@ export function azureDevOpsFilePatch(input: { newContents, undefined, undefined, - { context: PATCH_CONTEXT_LINES }, + { context: PATCH_CONTEXT_LINES, timeout: MAX_FILE_DIFF_MILLIS }, ); + // The bound is reported by giving nothing back, and a file whose diff was given up on is a file + // listed without its hunks rather than a file dropped from the change. + if (patch === undefined) return { section: `${header}\n`, truncated: true }; + const hunks = patch.hunks.map((hunk) => [ `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, From 644839c9cf5391c0116cdc290c8a06509dfa309e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 19:53:47 -0400 Subject: [PATCH 23/25] fix(server): a diff given up on no longer costs the whole slice again per file One file's diff was bounded but a slice of them was not, and the page opening a review passed the URL's spelling of a repository where the panel passed the server's. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.ts | 9 +++-- .../src/pullRequest/azureDevOpsDiff.test.ts | 35 +++++++++++++++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 24 +++++++++---- apps/web/src/lib/openPullRequestLink.ts | 12 ++++--- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 034df758f1ba..fcf059b0f183 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -8,6 +8,7 @@ import { formatAzureDevOpsDiffCursor, parseAzureDevOpsDiffCursor, MAX_DIFF_SLICE_BYTES, + MAX_FILE_DIFF_MILLIS, type AzureDevOpsFileTexts, } from "./azureDevOpsDiff.ts"; import { @@ -400,12 +401,16 @@ export const make = Effect.gen(function* () { const file = texts === null ? azureDevOpsUnreadableFilePatch(change) - : azureDevOpsFilePatch({ change, texts }); + : azureDevOpsFilePatch({ change, texts, timeoutMillis: MAX_FILE_DIFF_MILLIS }); sections.push(file.section); bytes += file.section.length; truncated = truncated || file.truncated; index += 1; - if (bytes >= MAX_DIFF_SLICE_BYTES) break; + // A file whose diff was given up on spent the whole of what one file is allowed and has + // a header to show for it, so the byte budget would let a change full of them spend that + // over and over in the one request. The slice ends there instead, and reading on picks + // up at the file behind it. + if (bytes >= MAX_DIFF_SLICE_BYTES || file.abandoned) break; } const slice: ProviderDiffSlice = { diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts index 585afdcb9d59..63b790b0a068 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -150,6 +150,41 @@ describe("azureDevOpsFilePatch", () => { ); }); + it("gives up on a file whose two sides are too far apart to diff in the time allowed", () => { + // The line diff costs the product of the two sides, so a pair under the size ceiling that + // shares nothing still runs long. Left to itself it would hold the server for as long as it + // took; here it is given a millisecond so the giving up is the thing being read. + const oldContents = Array.from({ length: 3_000 }, (_, line) => `old ${line}`).join("\n"); + const newContents = Array.from({ length: 3_000 }, (_, line) => `new ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(oldContents, newContents), + timeoutMillis: 1, + }); + + expect(patch.truncated).toBe(true); + // And it says so, because the reader of a run of files is meant to stop rather than spend + // that time again on each of the ones behind it. + expect(patch.abandoned).toBe(true); + expect(patch.section).toBe( + [ + "diff --git a/generated.ts b/generated.ts", + "--- a/generated.ts", + "+++ b/generated.ts", + "", + ].join("\n"), + ); + }); + + it("keeps a file it did diff out of the giving up", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\n", "one\ntwo again\n"), + }); + + expect(patch.abandoned).toBe(false); + }); + it("marks a file that does not end in a newline, as git does", () => { const patch = azureDevOpsFilePatch({ change: change(), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.ts b/apps/server/src/pullRequest/azureDevOpsDiff.ts index 39d7e6e52345..1ea63ac41edf 100644 --- a/apps/server/src/pullRequest/azureDevOpsDiff.ts +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -48,6 +48,12 @@ export interface AzureDevOpsFilePatch { readonly section: string; /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ readonly truncated: boolean; + /** + * The diff was given up on partway rather than declined on sight, so this file spent the whole + * of what one file is allowed and produced a header for it. The caller reading a run of files + * is meant to stop here rather than pay that again for each of the ones behind it. + */ + readonly abandoned: boolean; } /** @@ -66,7 +72,7 @@ const PATCH_CONTEXT_LINES = 3; * long time. Past this the file is listed without its hunks, which is what the size ceiling already * does and what the reader is already shown a sign of. */ -const MAX_FILE_DIFF_MILLIS = 2_000; +export const MAX_FILE_DIFF_MILLIS = 2_000; /** * How much patch one slice carries before the rest is left for the next one. Every file costs a @@ -122,6 +128,8 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { export function azureDevOpsFilePatch(input: { readonly change: AzureDevOpsChangeEntry; readonly texts: AzureDevOpsFileTexts; + /** How long this one file may be diffed for, at most what any file is allowed. */ + readonly timeoutMillis?: number; }): AzureDevOpsFilePatch { const header = patchHeader(input.change); const { oldContents, newContents } = input.texts; @@ -129,10 +137,10 @@ export function azureDevOpsFilePatch(input: { if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { // Git's own wording for a file it will not spell out, which every diff viewer already reads. const binary = `Binary files a/${input.change.oldPath} and b/${input.change.path} differ`; - return { section: `${header}\n${binary}\n`, truncated: true }; + return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false }; } if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { - return { section: `${header}\n`, truncated: true }; + return { section: `${header}\n`, truncated: true, abandoned: false }; } const patch = structuredPatch( @@ -142,11 +150,14 @@ export function azureDevOpsFilePatch(input: { newContents, undefined, undefined, - { context: PATCH_CONTEXT_LINES, timeout: MAX_FILE_DIFF_MILLIS }, + { + context: PATCH_CONTEXT_LINES, + timeout: Math.min(input.timeoutMillis ?? MAX_FILE_DIFF_MILLIS, MAX_FILE_DIFF_MILLIS), + }, ); // The bound is reported by giving nothing back, and a file whose diff was given up on is a file // listed without its hunks rather than a file dropped from the change. - if (patch === undefined) return { section: `${header}\n`, truncated: true }; + if (patch === undefined) return { section: `${header}\n`, truncated: true, abandoned: true }; const hunks = patch.hunks.map((hunk) => [ @@ -159,6 +170,7 @@ export function azureDevOpsFilePatch(input: { return { section: hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`, truncated: false, + abandoned: false, }; } @@ -170,5 +182,5 @@ export function azureDevOpsFilePatch(input: { export function azureDevOpsUnreadableFilePatch( change: AzureDevOpsChangeEntry, ): AzureDevOpsFilePatch { - return { section: `${patchHeader(change)}\n`, truncated: true }; + return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false }; } diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 888e3e8339d5..ceff696f2157 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -263,13 +263,15 @@ export function useOpenChangeRequestLink( if (project === undefined || !reads(project.environmentId)) return false; event.preventDefault(); event.stopPropagation(); + // The selector the server derives from the same identity, not the one read out of the URL: + // a ref spelled any other way is refused before it reaches a provider, and matching a link + // only ever compares lower case. The page reads it back the same way the panel does, so + // both surfaces are handed the same spelling. + const repository = pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository; if (resolvedThreadRef) { useRightPanelStore.getState().openPullRequest(resolvedThreadRef, { projectId: project.id, - // The selector the server derives from the same identity, not the one read out of the - // URL: a ref spelled any other way is refused before it reaches a provider, and - // matching a link only ever compares lower case. - repository: pullRequestRepositoryOf(project.repositoryIdentity) ?? parsed.repository, + repository, number: parsed.number, }); return true; @@ -281,7 +283,7 @@ export function useOpenChangeRequestLink( // Every state, so the pull request being opened is also in the list behind it whether // it is open, merged or closed. state: "all", - repository: parsed.repository, + repository, number: parsed.number, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. From b060805da72b4e7fcda2a390cea3c603ccd7c23c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 20:03:43 -0400 Subject: [PATCH 24/25] fix(server): a rate-limited Azure diff no longer reads as a change with no hunks A failed side read was degraded to a file listed without its hunks whatever the reason, so a signed-out or throttled host produced a whole change of empty files instead of a failure the app pauses on. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestCli.test.ts | 106 ++++++++++++++++++ .../AzureDevOpsPullRequestProvider.ts | 19 +++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 16bea79972ac..f02726e6d887 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -32,6 +32,27 @@ function output(stdout: string) { /** A fixture's own shape, spelled the way `az` would answer with it. */ const json = (value: Record) => JSON.stringify(value); +const pullRequestRow = { + pullRequestId: 42, + title: "Add the page", + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42", + repository: { name: "web", project: { name: "platform" } }, +}; + +const oneIteration = { + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], +}; + function pullRequestRows( count: number, firstNumber: number, @@ -815,6 +836,91 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("leaves one file the host would not hand over listed without its hunks", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } }, + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "the blob is past what the route will carry", + }), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" })))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + }); + + assert.isTrue(slice.truncated); + expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); + // And the file behind it still renders, which is the point of giving up on one file. + expect(slice.patch).toContain("+hello"); + }), + ); + + it.effect("fails the whole read when it is the connection that would not answer", () => + Effect.gen(function* () { + // A rate limit is not this file's problem, and answering with a change full of files listed + // without their hunks would read as a change nobody can see rather than as a host to wait + // for. + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) + .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) + .mockReturnValueOnce( + Effect.succeed( + output( + json({ + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } }, + ], + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 1, + cause: "429", + }), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const error = yield* Effect.flip( + provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }), + ); + + assert.strictEqual(error.reason, "rate-limited"); + }), + ); + it.effect("takes Azure's own word on a file it will not spell out", () => Effect.gen(function* () { mockedExecute diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index fcf059b0f183..56c8e7460939 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -20,7 +20,10 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; +import type { + AzureDevOpsIterationChanges, + AzureDevOpsPullRequestCliError, +} from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, AzureDevOpsItemContent, @@ -196,6 +199,18 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + /** + * Whether a failed side read is this one file's problem rather than the whole connection's. A + * path `az` will not carry, a blob it will not hand over and an answer that came back unreadable + * are all one file, and the rest of the change still renders around it. A signed-out CLI, a rate + * limit or no `az` at all is the read failing, and belongs to the caller, which pauses the host + * rather than showing every file in the change as unreadable. + */ + const isFileScopedReadFailure = (error: AzureDevOpsPullRequestCliError): boolean => + error._tag === "AzureDevOpsPullRequestNotFoundError" || + error._tag === "AzureDevOpsCommandFailedError" || + error._tag === "AzureDevOpsPullRequestReadError"; + /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -397,7 +412,7 @@ export const make = Effect.gen(function* () { location: scope.location, iteration, change, - }).pipe(Effect.orElseSucceed(() => null)); + }).pipe(Effect.catchIf(isFileScopedReadFailure, () => Effect.succeed(null))); const file = texts === null ? azureDevOpsUnreadableFilePatch(change) From 6e9d61c3cefc1b60c17054f2efa14f13f3796420 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 30 Aug 2026 20:09:42 -0400 Subject: [PATCH 25/25] fix(web): the viewed count no longer pushes the code toolbar off the strip The count was spelled out at a fixed width beside controls that cannot give way, and the Azure per-file recovery named its failures through a predicate where the tags say it plainly. Signed-off-by: Yordis Prieto --- .../AzureDevOpsPullRequestProvider.ts | 28 ++++++++----------- .../pullRequest/PullRequestCodeTab.tsx | 14 +++++++--- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 56c8e7460939..21d477b5a069 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -20,10 +20,7 @@ import { type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { - AzureDevOpsIterationChanges, - AzureDevOpsPullRequestCliError, -} from "./AzureDevOpsPullRequestCli.ts"; +import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; import type { AzureDevOpsChangeEntry, AzureDevOpsItemContent, @@ -199,18 +196,6 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; - /** - * Whether a failed side read is this one file's problem rather than the whole connection's. A - * path `az` will not carry, a blob it will not hand over and an answer that came back unreadable - * are all one file, and the rest of the change still renders around it. A signed-out CLI, a rate - * limit or no `az` at all is the read failing, and belongs to the caller, which pauses the host - * rather than showing every file in the change as unreadable. - */ - const isFileScopedReadFailure = (error: AzureDevOpsPullRequestCliError): boolean => - error._tag === "AzureDevOpsPullRequestNotFoundError" || - error._tag === "AzureDevOpsCommandFailedError" || - error._tag === "AzureDevOpsPullRequestReadError"; - /** * Both sides of one changed file. Only the sides a change actually has are asked for: Azure * answers for a file that is not at a commit with a failure rather than with nothing. @@ -412,7 +397,16 @@ export const make = Effect.gen(function* () { location: scope.location, iteration, change, - }).pipe(Effect.catchIf(isFileScopedReadFailure, () => Effect.succeed(null))); + }).pipe( + // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` at + // all is the read failing rather than the file, and belongs to the caller, which + // pauses the host rather than showing every file in the change as unreadable. + Effect.catchTags({ + AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), + AzureDevOpsCommandFailedError: () => Effect.succeed(null), + AzureDevOpsPullRequestReadError: () => Effect.succeed(null), + }), + ); const file = texts === null ? azureDevOpsUnreadableFilePatch(change) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 9fce1ed1647e..6bc64e541f9f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1147,11 +1147,17 @@ export function PullRequestCodeTab({ {nextCursor === null ? "" : "+"} {filesViewed.enabled && files.length > 0 ? ( - + {/* Named on a host that keeps no record of its own, so the reader is told whose - ticks these are without having to find the icon beside them. */} - {filesViewed.viewedCount} / {files.length}{" "} - {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} + ticks these are without having to find the icon beside them. The count holds its + width and the wording gives way, so this segment cannot push the controls on the + right off the strip in the narrow right panel. */} + + {filesViewed.viewedCount} / {files.length} + + + {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} + {viewedFilesStore === "environment" ? ( }>