From a20280ac1524db8c23f77a353849e30d09a0ed7a Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 04:35:06 -0600 Subject: [PATCH 01/16] Add provider-aware pull-request viewed files --- .../mobile/src/features/review/reviewModel.ts | 5 +- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/persistence/Errors.ts | 1 + apps/server/src/persistence/Migrations.ts | 3 + .../Migrations/063_PullRequestFilesViewed.ts | 25 + .../src/persistence/PullRequestFilesViewed.ts | 184 +++ .../AzureDevOpsPullRequestCli.test.ts | 854 +++++++++- .../pullRequest/AzureDevOpsPullRequestCli.ts | 245 ++- .../AzureDevOpsPullRequestProvider.test.ts | 420 +++++ .../AzureDevOpsPullRequestProvider.ts | 445 +++++- .../BitbucketPullRequestApi.test.ts | 155 ++ .../pullRequest/BitbucketPullRequestApi.ts | 119 +- .../BitbucketPullRequestProvider.ts | 34 +- .../pullRequest/ForgejoPullRequestProvider.ts | 13 + .../pullRequest/GitHubPullRequestCli.test.ts | 263 ++- .../src/pullRequest/GitHubPullRequestCli.ts | 125 +- .../pullRequest/GitHubPullRequestProvider.ts | 207 +-- .../pullRequest/GitLabPullRequestCli.test.ts | 167 ++ .../src/pullRequest/GitLabPullRequestCli.ts | 101 ++ .../pullRequest/GitLabPullRequestProvider.ts | 84 +- .../src/pullRequest/PullRequestProvider.ts | 61 + .../pullRequest/PullRequestService.test.ts | 1406 ++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 446 ++++-- .../src/pullRequest/azureDevOpsDiff.test.ts | 459 ++++++ .../server/src/pullRequest/azureDevOpsDiff.ts | 273 ++++ .../azureDevOpsPullRequestJson.test.ts | 325 +++- .../pullRequest/azureDevOpsPullRequestJson.ts | 228 ++- .../bitbucketDiffRevisions.test.ts | 280 ++++ .../src/pullRequest/bitbucketDiffRevisions.ts | 129 ++ .../pullRequest/gitHubPullRequestJson.test.ts | 147 ++ .../src/pullRequest/gitHubPullRequestJson.ts | 176 ++- .../gitLabMergeRequestJson.test.ts | 171 ++ .../src/pullRequest/gitLabMergeRequestJson.ts | 91 +- .../src/pullRequest/pullRequestViewedFiles.ts | 423 +++++ apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 3 + .../sourceControl/azureDevOpsPullRequests.ts | 8 +- apps/server/src/ws.ts | 12 +- .../pullRequest/PullRequestCodeTab.tsx | 216 ++- .../pullRequest/pullRequestDiff.logic.test.ts | 33 +- .../pullRequest/pullRequestDiff.logic.ts | 21 + .../pullRequestFilesViewed.logic.test.ts | 174 ++ .../pullRequestFilesViewed.logic.ts | 100 ++ .../usePullRequestFilesViewed.test.tsx | 161 ++ .../pullRequest/usePullRequestFilesViewed.ts | 211 +++ apps/web/src/lib/diffRendering.test.ts | 59 + apps/web/src/lib/diffRendering.ts | 15 +- apps/web/src/lib/openPullRequestLink.test.ts | 27 + docs/README.md | 1 + docs/internals/pull-request-file-revisions.md | 65 + docs/user/source-control.md | 18 +- .../client-runtime/src/state/pullRequests.ts | 19 + packages/contracts/src/pullRequest.test.ts | 67 + packages/contracts/src/pullRequest.ts | 75 + packages/contracts/src/rpc.ts | 18 + packages/shared/package.json | 8 + packages/shared/src/git.test.ts | 32 + packages/shared/src/git.ts | 37 +- packages/shared/src/gitPatchPath.test.ts | 97 ++ packages/shared/src/gitPatchPath.ts | 140 ++ packages/shared/src/sourceControl.test.ts | 14 + pnpm-lock.yaml | 3 + 63 files changed, 9224 insertions(+), 484 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts create mode 100644 apps/server/src/persistence/PullRequestFilesViewed.ts create mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.test.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsDiff.ts create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts create mode 100644 apps/server/src/pullRequest/bitbucketDiffRevisions.ts create mode 100644 apps/server/src/pullRequest/pullRequestViewedFiles.ts 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.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts create mode 100644 docs/internals/pull-request-file-revisions.md create mode 100644 packages/shared/src/gitPatchPath.test.ts create mode 100644 packages/shared/src/gitPatchPath.ts diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 2cb8c61a16..06fe4c9597 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -372,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray total + hunk.additionLines, 0); const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; diff --git a/apps/server/package.json b/apps/server/package.json index e48d2dd3f5..81caf8671b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ "@sigstore/core": "3.2.1", "@sigstore/tuf": "4.0.0", "@sigstore/verify": "3.0.0", + "diff": "8.0.3", "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 31bc327584..e5db34e77c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -103,6 +103,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, @@ -111,6 +112,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/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 7d6cf17019..3c00b68dd0 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -176,5 +176,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 a50bb20be3..0693b00074 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0059 from "./Migrations/059_ProjectionThreadPullRequests.ts"; import Migration0060 from "./Migrations/060_ProjectionThreadCompactionQueue.ts"; import Migration0061 from "./Migrations/061_ProjectionThreadMessageContext.ts"; import Migration0062 from "./Migrations/062_RepairStoppedSessionRequests.ts"; +import Migration0063 from "./Migrations/063_PullRequestFilesViewed.ts"; /** * Migration loader with all migrations defined inline. * @@ -178,6 +179,8 @@ const migrationEntries = [ // Upstream 51 is already Pylon durable rollback; retain that lineage and append. [61, "ProjectionThreadMessageContext", Migration0061], [62, "RepairStoppedSessionRequests", Migration0062], + // Upstream used 53 for viewed files; Pylon's persisted lineage already runs through 62. + [63, "PullRequestFilesViewed", Migration0063], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts new file mode 100644 index 0000000000..5e7f67c0b1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts @@ -0,0 +1,25 @@ +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, and it is nullable for the reason `PullRequestFileViewedMark` + // gives. 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, + 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 0000000000..9904e24401 --- /dev/null +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -0,0 +1,184 @@ +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 the same + * `group/project` exists on gitlab.com and on a self-managed instance, and the reader because + * 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 said it + * had none to give, which is an answer rather than a gap: a file with no version at the head is + * one the change request deletes. Null where the host could not say at all, which is no baseline + * rather than an empty one, and such a mark stays cleared until a press replaces it. + * + * This null is the only one this environment invents; the other two are in + * `docs/internals/pull-request-file-revisions.md`. + */ + revision: Schema.NullOr(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; +} + +/** + * How many marks one read of this store carries. Every one of them is a path held in a set and a + * map for as long as the caller holds the read, so an unbounded read of a change request with + * thousands of marks in it is paid for again per scope the caller is holding. Matched to what the + * GitHub reader walks in one go, past what anyone reviews in a sitting. + */ +export const MAX_FILES_VIEWED_ROWS = 500; + +/** The marks for one scope, and whether the store had more of them than it carried. */ +export interface PullRequestFilesViewedPage { + readonly files: ReadonlyArray; + readonly truncated: boolean; +} + +/** + * 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; + 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 }); +} + +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} + ORDER BY path + LIMIT ${MAX_FILES_VIEWED_ROWS + 1} + `, + }); + + return PullRequestFilesViewedRepository.of({ + // Ordered by path and read one row past the ceiling, so the same marks come back on every + // read rather than a window that shuffles, and having more than were carried is known rather + // than guessed at from a full page. + list: (input) => + listRows(input).pipe( + Effect.map((rows) => ({ + files: rows.slice(0, MAX_FILES_VIEWED_ROWS), + truncated: rows.length > MAX_FILES_VIEWED_ROWS, + })), + 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) => + // One transaction for the batch. A press is a handful of files, and a failure part way + // through would otherwise leave some of them cleared and the rest not, which the reader + // sees on the next read as marks they never made. + sql + .withTransaction( + 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/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index d893924b3f..6e378c43b1 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -29,6 +29,45 @@ function output(stdout: string) { }; } +/** What VcsProcess allows a read that asked for no ceiling of its own. */ +const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +/** + * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts + * it at the process default when the caller asked for none. A read whose response is larger than + * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. + */ +function outputWithin(maxOutputBytes: number | undefined, response: string) { + const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; + return ceiling >= Buffer.byteLength(response) + ? output(response) + : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; +} + +/** 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, @@ -56,6 +95,92 @@ function argsOfCall(index: number): ReadonlyArray { return call[0].args; } +/** The output ceiling the nth az invocation asked for, if it asked for one at all. */ +function maxOutputBytesOfCall(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].maxOutputBytes; +} + +/** A page of change entries the size Azure really answers with, url and object ids and all. */ +function changeEntries(count: number): ReadonlyArray> { + const commit = "c".repeat(40); + return Array.from({ length: count }, (_, index) => { + const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`; + return { + changeType: "edit", + item: { + path, + objectId: "a".repeat(40), + originalObjectId: "b".repeat(40), + commitId: commit, + gitObjectType: "blob", + url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`, + }, + }; + }); +} + +/** An Azure identity, which rides along with every comment and every push Azure answers with. */ +function identity(name: string) { + const id = "6f9c9b7f-0000-0000-0000-000000000000"; + return { + displayName: name, + id, + uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`, + descriptor: `aad.${"z".repeat(52)}`, + imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`, + url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`, + _links: { + avatar: { + href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`, + }, + }, + }; +} + +/** A review's threads the shape Azure answers with, system threads and identities and all. */ +function threadRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + status: "active", + threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` }, + identities: { 1: identity("Reviewer One") }, + isDeleted: false, + comments: [ + { + id: 1, + parentCommentId: 0, + author: identity("Reviewer One"), + content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`, + publishedDate: "2026-07-02T00:00:00Z", + lastUpdatedDate: "2026-07-02T00:00:00Z", + commentType: "text", + usersLiked: [], + }, + ], + })); +} + +/** A review's iterations the shape Azure answers with, one per push. */ +function iterationRows(count: number): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + description: `Pushed ${index} commits`, + author: identity("Author One"), + createdDate: "2026-07-02T00:00:00Z", + updatedDate: "2026-07-02T00:00:00Z", + sourceRefCommit: { commitId: `${index}`.padStart(40, "a") }, + targetRefCommit: { commitId: "b".repeat(40) }, + commonRefCommit: { commitId: "c".repeat(40) }, + hasMultipleCommits: true, + reason: "push", + push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") }, + })); +} + afterEach(() => { mockedExecute.mockReset(); }); @@ -107,20 +232,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const response = JSON.stringify(rows); expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); - mockedExecute.mockImplementationOnce((input) => { - const maxOutputBytes = - "maxOutputBytes" in input && typeof input.maxOutputBytes === "number" - ? input.maxOutputBytes - : 1_000_000; - return Effect.succeed( - maxOutputBytes >= Buffer.byteLength(response) - ? output(response) - : { - ...output(response.slice(0, maxOutputBytes)), - stdoutTruncated: true, - }, - ); - }); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; const batch = yield* cli.listPullRequests({ @@ -516,6 +630,590 @@ 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("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( + JSON.stringify({ + value: [ + { + id: 1, + sourceRefCommit: { commitId: "a".repeat(40) }, + commonRefCommit: { commitId: "b".repeat(40) }, + }, + ], + }), + ), + ); + const changes = () => + Effect.succeed( + output( + 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( + "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 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("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 + .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 + .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", "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"], + ]); + }), + ); + + it.effect("asks Azure nothing when no file has been ticked off", () => + Effect.gen(function* () { + 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( @@ -539,14 +1237,134 @@ 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"); + // A review's threads grow with how long it ran, and this route does not page, so the read + // asks for more than the process default rather than taking whatever it is given. + expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + }), + ); + + it.effect("reads a long review's threads, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: threadRows(800) }); + // Azure opens a thread per vote and per ref update beside the ones people wrote, and every + // comment carries a full identity, so a review argued over for weeks outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(comments.length, 800); + }), + ); + + it.effect("reads a long review's iterations, which are past the default output limit", () => + Effect.gen(function* () { + const response = json({ value: iterationRows(1_200) }); + // This route does not page, so the whole history arrives at once. Cut at the default it is + // JSON stopping mid-string, and every diff and file revision read on this host fails with + // it, since each of them starts by asking which iteration is the latest. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const iterations = yield* cli.listIterations({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + }); + + assert.strictEqual(iterations.length, 1_200); + assert.strictEqual(iterations.at(-1)?.id, 1_200); + }), + ); + + it.effect("reads a full page of change entries, which is past the default output limit", () => + Effect.gen(function* () { + const response = json({ changeEntries: changeEntries(2_000) }); + // Azure's own maximum for this route, and every entry carries a path, a url and three + // object ids, so an ordinary page of a large change already outgrows the default. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const page = yield* cli.listIterationChanges({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + number: 42, + iterationId: 1, + }); + + // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly + // ordinary page would be reported as a host answering with nonsense. + assert.strictEqual(page.changes.length, 2_000); + assert.isFalse(page.truncated); + }), + ); + + it.effect("reads a file whose JSON envelope is past the default output limit", () => + Effect.gen(function* () { + // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. + const file = "const value = 1;\n".repeat(57_000); + const response = json({ content: file }); + expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + // And past it once Azure wraps it, because there is no route here that serves the bytes. + expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); + mockedExecute.mockImplementationOnce((input) => + Effect.succeed(outputWithin(input.maxOutputBytes, response)), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const item = yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/generated/schema.ts", + commit: "a".repeat(40), + }); + + assert.strictEqual(item.contents, file); + assert.isFalse(item.isBinary); + }), + ); + + it.effect("asks for a file by Azure's own spelling of its path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + // The path carried around here has had Azure's leading slash taken off so it matches the + // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes + // back on the way out rather than being sent as the shorter name. + yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "src/app.ts", + commit: "a".repeat(40), + }); + + expect(argsOfCall(0)).toContain("path=/src/app.ts"); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 91a39e8de3..5f5453a9b3 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -13,11 +13,18 @@ import type { import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import { + decodeItemContentJson, + decodeIterationChangesJson, + decodeIterationsJson, decodePullRequestJson, decodePullRequestListJson, decodeThreadsJson, decodeViewerJson, + type AzureDevOpsChangeEntry, + type AzureDevOpsItemContent, + type AzureDevOpsIteration, type AzureDevOpsPullRequest, + type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; @@ -112,6 +119,44 @@ 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"; const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +/** + * A full page of change entries is two thousand files, each carrying its path, its url and + * several object ids, which is past the megabyte a read is given by default. Output cut at that + * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would + * report itself as a host returning nonsense rather than as the ordinary page it is. + */ +const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** + * Four times the megabyte of file the other hosts hand over, because Azure has no route that + * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and + * base64 if it is not, and both are larger than the file they carry. + */ +const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +/** + * What a review's own history is given. Neither route pages, so each answers with the whole of it + * at once and grows with how long the review ran rather than with how large the change is. Cut at + * the default, both arrive as JSON that stops mid-string, and a long review would report its host + * as answering with nonsense. + */ +const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; + +/** Azure's own ceiling for one page of an iteration's changes. */ +const CHANGE_ENTRIES_PER_PAGE = 2000; + +/** + * Where following the pages stops, counted in the entries Azure was asked to skip rather than in + * the files that survived decoding: a page can be entirely folders and other entries a review has + * nothing to show for, and bounding on what was kept would follow such a change forever. Every + * page is an `az` process of its own, so the read gives up and reports itself incomplete rather + * than 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, @@ -150,9 +195,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; + + /** + * 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; @@ -281,6 +359,77 @@ export const make = Effect.gen(function* () { ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }); + /** + * A REST route reached through `az devops invoke`, which addresses it by area, resource and + * route parameters rather than by URL. Used in place of `az rest` because it signs in the way + * the azure-devops extension does: `az rest` mints its own token against the tenant `az` + * defaults to, and an organisation in any other tenant answers that 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 maxOutputBytes?: number; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + executeJson({ + cwd: input.cwd, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + 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, + }), + ); + }), + ); + + /** + * Azure names its own items with a leading slash, which the repository paths carried around + * here have had taken off so they match the patch and the viewed mark. Put it back on the way + * out, because the items route is documented in Azure's own spelling. + */ + const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); + + 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. @@ -448,30 +597,80 @@ export const make = Effect.gen(function* () { ), listThreads: (input) => - executeJson({ + invoke({ cwd: input.cwd, - args: [ - "rest", - "--method", - "get", - "--url", - `${input.threadsUrl}?api-version=${REST_API_VERSION}`, + operation: "listThreads", + resource: "pullRequestThreads", + routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodeThreadsJson, + }), + + listIterations: (input) => + invoke({ + cwd: input.cwd, + operation: "listIterations", + resource: "pullRequestIterations", + routeParameters: pullRequestRoute(input), + maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, + decode: decodeIterationsJson, + }), + + 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}`], + maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, + decode: decodeIterationChangesJson, + }); + const from = ( + skip: number, + collected: ReadonlyArray, + ): Effect.Effect => + page(skip).pipe( + Effect.flatMap((answer) => { + const changes = [...collected, ...answer.changes]; + // 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); + }), + ); + return from(0, []); + }, + + readItemContent: (input) => + invoke({ + cwd: input.cwd, + operation: "readItemContent", + resource: "items", + routeParameters: repositoryRoute(input.location), + queryParameters: [ + `path=${toItemPath(input.path)}`, + "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", ], - }).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, - }), - ); - }), - ), + maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, + decode: decodeItemContentJson, + }), setPullRequestReviewers: (input) => input.reviewers.some((reviewer) => !isReviewerName(reviewer)) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 0000000000..1b96984cf7 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + LOCATION_CACHE_CAPACITY, + make, + MAX_DIFF_SPAWNS, +} from "./AzureDevOpsPullRequestProvider.ts"; +import { + byteLength, + MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_FILES, + MAX_FILE_DIFF_EDITS, + parseAzureDevOpsDiffCursor, +} from "./azureDevOpsDiff.ts"; +import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; + +const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; + +const PULL_REQUEST = { + number: 7, + title: "Pull request 7", + url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + closedAt: null, + body: "", + reviewRequestLogins: [], + reviewers: [], + location: { project: "acme", repository: "web" }, + autoMergeEnabled: false, +}; + +function change( + path: string, + changeKind: AzureDevOpsChangeEntry["changeKind"] = "change", +): AzureDevOpsChangeEntry { + return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; +} + +/** + * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of + * `width` characters each: the diff work and the patch bytes one file costs are both dialled from + * here, and they are what a slice is bounded by. Each line carries its own number and a prefix + * as well, so `width` is a floor on how long a line is rather than its byte count. + */ +function side(prefix: string, lines: number, width: number): string { + const pad = "z".repeat(width); + return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; +} + +const readSlice = (input: { + readonly paths: ReadonlyArray; + readonly lines: number; + readonly width: number; + /** Paths the host refuses, which is one file's problem rather than the read's. */ + readonly refused?: ReadonlyArray; + /** Paths the change creates, so the host has nothing to hand back for their old side. */ + readonly created?: ReadonlyArray; + readonly cursor?: string; +}) => + Effect.gen(function* () { + const reads: string[] = []; + const refused = new Set(input.refused ?? []); + const created = new Set(input.created ?? []); + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ + changes: input.paths.map((path) => + change(path, created.has(path) ? "new" : "change"), + ), + truncated: false, + }), + readItemContent: (item) => + Effect.gen(function* () { + reads.push(item.path); + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Every read suspends before it answers, as a subprocess would, so what runs at + // once is the scheduler's answer rather than an artefact of resolving inline. The + // later a file is listed the sooner it answers, to leave the assembled patch + // nothing but the change list to take its order from. + const answersAfter = input.paths.length - input.paths.indexOf(item.path); + for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; + inFlight -= 1; + if (refused.has(item.path)) { + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: "/w", + operation: "readItemContent", + cause: "refused", + }); + } + const isOldSide = item.commit !== ITERATION.headCommit; + if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false }; + return { + contents: side(isOldSide ? "old" : "new", input.lines, input.width), + isBinary: false, + }; + }), + }), + ), + ); + + const slice = yield* provider.getDiff({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }); + return { slice, reads, peakInFlight }; + }); + +/** Which files the patch carries a section for, in the order it carries them. */ +function patchedPaths(patch: string): ReadonlyArray { + return [...patch.matchAll(/^diff --git a\/(?\S+) b\//gmu)].map( + (match) => match.groups?.path ?? "", + ); +} + +describe("getChangeRequestSummary", () => { + it.effect("costs the one pull request read, not the iterations changedFiles needs", () => + Effect.gen(function* () { + let pullRequestReads = 0; + + const provider = yield* make.pipe( + Effect.provide( + // listIterations and listIterationChanges are left unimplemented here, so a summary + // that reached for either would die with UnimplementedError instead of this passing. + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => { + pullRequestReads += 1; + return Effect.succeed(PULL_REQUEST); + }, + }), + ), + ); + + const readSummary = provider.getChangeRequestSummary; + if (readSummary === undefined) return yield* Effect.die("summary read was not implemented"); + const summary = yield* readSummary({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }); + + expect(pullRequestReads).toBe(1); + expect(summary.title).toBe(PULL_REQUEST.title); + expect(summary.changedFiles).toBeUndefined(); + }), + ); +}); + +describe("getChangeRequest", () => { + it.effect("still reports the file count the detail panel needs", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }), + }), + ), + ); + + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + }); + + expect(detail.changedFiles).toBe(2); + }), + ); +}); + +describe("getDiff reads", () => { + it.effect("holds every reader together to one request's worth of processes", () => + Effect.gen(function* () { + // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at + // once are two reads, so without a ceiling above them both they are twice a request's + // processes, each paying a Python interpreter's start-up on the same machine. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + let inFlight = 0; + let peakInFlight = 0; + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }), + readItemContent: () => + Effect.gen(function* () { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + // Suspends before answering, as a subprocess would, so what is out at once is the + // scheduler's answer rather than an artefact of resolving inline. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + inFlight -= 1; + return { contents: side("new", 2, 4), isBinary: false }; + }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 }); + + expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS); + }), + ); + + it.effect("leaves a run of files it could not diff at all for the next slice", () => + Effect.gen(function* () { + // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of + // hundred bytes with no edits in it, so a change made of them spends neither budget: the + // byte one would take well over a thousand of them, and the edit one never fills at all. + // A listing holds up to ten thousand entries and each still costs its two reads, which is + // what a file count is here to bound. + const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); + + expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); + // Well inside the byte budget, so the file count is what stopped it rather than either of + // the budgets that were already there. + expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( + MAX_DIFF_SLICE_FILES, + ); + }), + ); + + it.effect("asks for both sides of several files at once rather than one side at a time", () => + Effect.gen(function* () { + // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a + // slice read one side after another is most of what the Code tab waits for. + const read = yield* readSlice({ + paths: ["a.ts", "b.ts", "c.ts", "d.ts"], + lines: 2, + width: 4, + }); + + expect(read.peakInFlight).toBeGreaterThan(2); + }), + ); + + it.effect("holds the number of files it reads at once down", () => + Effect.gen(function* () { + // The host throttles, and `az` is a process on the same machine the reader runs agents on, + // so a long change is read in batches rather than all at once. + const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(read.reads).toHaveLength(paths.length * 2); + expect(read.peakInFlight).toBeLessThanOrEqual(8); + }), + ); + + it.effect("keeps the patch in the order the change was listed, whoever answered first", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4 }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.nextCursor).toBeNull(); + }), + ); + + it.effect("leaves a file the host refused listed without its hunks, in its place", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts"]; + const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths); + expect(read.slice.truncated).toBe(true); + // Its section ends at its header, and the files around it still carry their hunks. + expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); + expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); + }), + ); +}); + +describe("what one diff slice spends", () => { + it.effect("stops on the byte ceiling without carrying what it read past it", () => + Effect.gen(function* () { + // Two of these fill the slice, and the batch they were read in reached two files further. + // Those two belong to the next slice: carrying them would put the request past a ceiling + // that is there to bound what one answer weighs. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ paths, lines: 100, width: 900 }); + + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`); + expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"])); + }), + ); + + it.effect("narrows what it reads at once as the slice fills", () => + Effect.gen(function* () { + // Four files fit inside the budget and the fifth is past half of what is left of it, so + // reading four more would throw most of them away and read them again next slice. Every + // file is two `az` invocations, so the batch is judged against what the files before it + // weighed rather than left at its full width to the last file. + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; + const read = yield* readSlice({ paths, lines: 50, width: 450 }); + + expect(read.slice.nextCursor).not.toBeNull(); + expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch))); + }), + ); + + it.effect("stops once the diff work one request may do is spent", () => + Effect.gen(function* () { + // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let + // one request hold the thread through a dozen of them. Each of these is half of what one + // file is allowed, and the slice ends while there is still room for another. + const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); + const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); + + expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES); + expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5)); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`); + }), + ); + + it.effect("carries a whole new file and stops the slice on what it weighed", () => + Effect.gen(function* () { + // A creation has no edit distance to search out, so no edit bound applies to it and its + // section is the whole file. What keeps a run of them from filling one answer is the bytes + // they weighed, which the slice has to be charged for. + const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; + const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); + + expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]); + expect(read.slice.patch).toContain("--- /dev/null"); + expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@"); + expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`); + }), + ); + + it.effect("carries on from where the last slice stopped", () => + Effect.gen(function* () { + const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; + const read = yield* readSlice({ + paths, + lines: 100, + width: 900, + cursor: `${ITERATION.id}:2`, + }); + + expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`); + }), + ); + it.effect("keeps the pull request being read, not the one looked up first", () => + Effect.gen(function* () { + // Where a pull request lives is read from the pull request itself, so an evicted entry + // costs a whole pull request read before any file can be asked for. Ordered by insertion + // alone a hit does not renew its entry, so the review being worked through is the first + // thing dropped once a listing has walked a cache's worth of cold pull requests. + const HOT = 7; + const readsOf = new Map(); + + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: (input) => + Effect.sync(() => { + readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1); + return { ...PULL_REQUEST, number: input.number }; + }), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ changes: [change("a.ts")], truncated: false }), + readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }), + }), + ), + ); + + const readDiff = (number: number) => + provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); + + yield* readDiff(HOT); + // A cache's worth of cold pull requests, with the open one read in between each of them. + for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { + yield* readDiff(HOT + 1 + filled); + yield* readDiff(HOT); + } + + expect(readsOf.get(HOT)).toBe(1); + }), + ); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index ae586ee0a6..cc84ab6c0a 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -1,21 +1,62 @@ import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, + formatAzureDevOpsDiffCursor, + parseAzureDevOpsDiffCursor, + MAX_DIFF_SLICE_BYTES, + MAX_DIFF_SLICE_EDITS, + MAX_DIFF_SLICE_FILES, + byteLength, + MAX_FILE_DIFF_EDITS, + type AzureDevOpsFileTexts, +} from "./azureDevOpsDiff.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, type ProviderChangeRequest, type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, + type ProviderChangeRequestSummary, + type ProviderDiffSlice, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; +import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; +import type { + AzureDevOpsChangeEntry, + AzureDevOpsItemContent, + AzureDevOpsIteration, + AzureDevOpsPullRequest, + AzureDevOpsRepositoryLocation, +} from "./azureDevOpsPullRequestJson.ts"; + +/** + * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a + * Python interpreter's start-up, so reading them one after another is most of what the Code tab + * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its + * per-file stats with here, and low enough not to swamp the host's throttling or the machine. + */ +const DIFF_FILE_CONCURRENCY = 4; + +/** + * How many `az` processes this build will have out at once, counted across every reader rather + * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews + * had sixteen Python interpreters starting at once and nothing above them. Held at what one + * request at full width spends, so a second reader waits behind the first instead of adding to it. + */ +export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; + +/** How many pull requests' repository locations one provider remembers at once. */ +export const LOCATION_CACHE_CAPACITY = 128; 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 +74,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 +86,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 +132,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, @@ -101,6 +148,11 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq export const make = Effect.gen(function* () { const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + // Made once with the provider, which the registry builds once, so this is the whole build's + // allowance rather than one request's. + const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); + const readItemContent = (input: Parameters[0]) => + diffSpawns.withPermits(1)(cli.readItemContent(input)); const fail = (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => @@ -123,6 +175,125 @@ 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. Least + * recently used, so a listing walking cold pull requests cannot evict the one being read. + */ + 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) { + locations.delete(key); + locations.set(key, held); + 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 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 location = yield* locationOf(input); + if (location === null) return null; + const iterations = yield* cli.listIterations({ + cwd: input.cwd, + location, + number: input.number, + }); + return { location, iterations }; + }); + + const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; + + /** + * Both sides of one changed file, read at once because neither answer depends on the other and + * `az` pays a Python interpreter's start-up for each. 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.all( + [ + input.change.changeKind === "new" + ? Effect.succeed(EMPTY_ITEM) + : readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.oldPath, + commit: input.iteration.mergeBaseCommit, + }), + input.change.changeKind === "deleted" + ? Effect.succeed(EMPTY_ITEM) + : readItemContent({ + cwd: input.cwd, + location: input.location, + path: input.change.path, + commit: input.iteration.headCommit, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map( + ([oldItem, newItem]): 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, + }), + ), + ); + + /** + * 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({ changes: [], truncated: false } as AzureDevOpsIterationChanges) + : cli.listIterationChanges({ + cwd: input.cwd, + location: input.location, + number: input.number, + iterationId: latest.id, + }); + }; + const provider: PullRequestProviderApi = { kind: "azure-devops", capabilities: CAPABILITIES, @@ -156,13 +327,54 @@ export const make = Effect.gen(function* () { })), ), - getChangeRequest: (input) => + // The polled path a linked thread's row stays live on: one `az` read, no iterations or + // changes behind it, since the file count that would cost is not shown here. + getChangeRequestSummary: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( - Effect.mapError(fail("getChangeRequest")), - Effect.map((pullRequest): ProviderChangeRequestDetail => ({ + Effect.mapError(fail("getChangeRequestSummary")), + Effect.map( + (pullRequest): ProviderChangeRequestSummary => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + updatedAt: pullRequest.updatedAt, + }), + ), + ), + + getChangeRequest: (input) => + 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((listed) => listed.changes.length), + Effect.orElseSucceed(() => 0), + ); + const detail: ProviderChangeRequestDetail = { ...toChangeRequest(pullRequest), body: pullRequest.body, - changedFiles: 0, + changedFiles, mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, reviewers: pullRequest.reviewers, @@ -173,27 +385,36 @@ export const make = Effect.gen(function* () { ...(pullRequest.autoMergeMethod === undefined ? {} : { autoMergeMethod: pullRequest.autoMergeMethod }), - })), - ), + }; + 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 => ({ - comments: conversation.comments, - commentCount: conversation.comments.length, - commentsTruncated: conversation.truncated, - reviewThreads: [], - commits: [], - })), + Effect.map( + (conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + }), + ), ), ), ), @@ -202,16 +423,170 @@ 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 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 = listed.truncated; + let bytes = 0; + let edits = 0; + let index = cursor?.fileIndex ?? 0; + let full = false; + // How many files to read at once. Every file costs a request a side, and everything read + // past the point the slice fills is thrown away and read again by the next slice, so the + // batch narrows as the budgets do: what is left of each, over what a file has spent of it + // on average so far, which is the only estimate there is before a file is read. At least + // one, so a file heavier than the whole budget still moves the cursor. + const batchWidth = () => { + if (sections.length === 0) return DIFF_FILE_CONCURRENCY; + const admits = (left: number, spent: number) => + Math.ceil(left / Math.max(1, spent / sections.length)); + return Math.max( + 1, + Math.min( + DIFF_FILE_CONCURRENCY, + // The file budget needs no estimate: a file spends exactly one of it. + MAX_DIFF_SLICE_FILES - sections.length, + admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), + admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), + ), + ); + }; + while (!full && index < changes.length) { + const batch = changes.slice(index, index + batchWidth()); + const read = yield* Effect.forEach( + batch, + (change) => + // 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. + readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).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 as unreadable. + Effect.catchTags({ + AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), + AzureDevOpsCommandFailedError: () => Effect.succeed(null), + AzureDevOpsPullRequestReadError: () => Effect.succeed(null), + }), + Effect.map((texts) => ({ change, texts })), + ), + { concurrency: DIFF_FILE_CONCURRENCY }, + ); + for (const { change, texts } of read) { + // The diff is synchronous and the reads no longer stand between one file and the next + // to let anything else on the server run, so the thread is handed back here. + yield* Effect.yieldNow; + const file = + texts === null + ? azureDevOpsUnreadableFilePatch(change) + : azureDevOpsFilePatch({ change, texts }); + sections.push(file.section); + bytes += byteLength(file.section); + edits += file.edits; + truncated = truncated || file.truncated; + index += 1; + // A file whose diff was given up on spent the whole of what one file is allowed and + // has only a header to show for it, and a file the diff never ran on at all weighs + // almost nothing in either budget and still costs its two reads. So the file count + // bounds the request alongside the bytes. + // Checked after the file is added rather than before it, so every slice carries at + // least one: a section heavier than the whole budget would otherwise never be added, + // and the read would answer the same slice forever without moving the cursor. + if ( + bytes >= MAX_DIFF_SLICE_BYTES || + edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || + sections.length >= MAX_DIFF_SLICE_FILES || + file.abandoned + ) { + full = true; + 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. Read against the latest iteration: nothing in + // the request says which push the reader is looking at, and expansion is stale after a + // mid-review push on every host here anyway. + 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, since the latest iteration lists the whole change. + * + * A path the change does not carry is at the empty revision, which is where a file the pull + * request deletes sits. When the change was too long to follow to its end, those paths are + * left out instead: reporting them as deleted would clear a file nobody has read. + */ + 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 listed = yield* listLatestChanges({ + ...scope, + cwd: input.cwd, + number: input.number, + }); + const marked = new Set(input.paths); + 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"))), runAction: (input) => cli diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 8945ecc5e1..49cdaa90f7 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -1,6 +1,8 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -362,6 +364,159 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect("reads every file version the patch states, not only the paths 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` was not asked about and is reported anyway: parsing the patch for `a.ts` read it + // too, and the caller holding it is what stops the next tick paying for the patch again. + // `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.revisions], + [ + ["a.ts", "2222222"], + ["b.ts", "4444444"], + ["missing.ts", ""], + ], + ); + assert.strictEqual(revisions.complete, true); + expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); + }), + ); + + 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({ + 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 revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts", "past-the-cut.ts"], + }); + + assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); + // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. + assert.strictEqual(revisions.complete, false); + }), + ); + + it.effect("reads the patch once for a run of ticks, not once a tick", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "@@ -1 +1 @@", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "@@ -1 +1 @@", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const first = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["a.ts"], + }); + // A path nobody has asked about before, which is what every tick after the first names. + const second = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["b.ts"], + }); + + const both = [ + ["a.ts", "2222222"], + ["b.ts", "4444444"], + ]; + assert.deepStrictEqual([...first.revisions], both); + assert.deepStrictEqual([...second.revisions], both); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("reads the patch afresh once the one it held has aged out", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + 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 read = () => + api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); + + yield* read(); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + + // Well inside the window the caller holds versions for: a refresh drops what it holds so + // that the read after it reaches Bitbucket, and this must not answer that read instead. + yield* TestClock.adjust(Duration.seconds(30)); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + 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.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 632f0d9a55..c7d017071f 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,7 +139,13 @@ 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; - +/** + * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the + * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it + * instead. + */ +const REVISION_PATCH_TTL = Duration.seconds(5); +const REVISION_PATCH_CAPACITY = 16; export interface BitbucketPullRequestBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -182,6 +192,25 @@ 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. 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. + * + * Answers with every file the patch carries rather than only the paths asked about, since + * reading one file's version here means parsing all of them. `complete` is false for a patch + * cut short, which cannot speak for what came after the cut. + */ + readonly getFileRevisions: (input: { + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect< + { readonly revisions: ReadonlyMap; readonly complete: boolean }, + BitbucketPullRequestApiError + >; + readonly getMergeability: (input: { readonly repository: string; readonly number: number; @@ -525,6 +554,58 @@ 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 })), + ), + ); + + /** + * What the version reads that come one tick at a time want out of the pull request's whole + * patch, shared between them. The parsed answer rather than the patch, which at this capacity + * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch + * of a hundred thousand lines again on every tick. + */ + const revisionPatches = yield* Cache.makeWith( + (key: string) => { + const [repository, number] = JSON.parse(key) as [string, number]; + return pullRequestDiff({ repository, number }).pipe( + Effect.map((diff) => ({ + revisions: parseDiffFileRevisions(diff.patch), + truncated: diff.truncated, + })), + ); + }, + { + capacity: REVISION_PATCH_CAPACITY, + // A failure is not held: the tick after it should reach Bitbucket rather than be handed the + // same error for as long as a good patch would have lasted. + timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), + }, + ); + return BitbucketPullRequestApi.of({ getViewer: () => bitbucket.request({ method: "GET", url: "/user" }).pipe( @@ -596,25 +677,23 @@ 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({ revisions: new Map(), complete: false }) + : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( + Effect.map((diff) => { + const revisions = new Map(diff.revisions); + // 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. + if (!diff.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return { revisions, complete: !diff.truncated }; + }), ), getDiffStat: (input) => diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 49afbcf204..c26c0ad342 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", }; /** @@ -229,15 +234,17 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ - comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: comments.comments.length + pullRequest.reviews.length, - commentsTruncated: comments.truncated, - reviewThreads: comments.threads, - commits, - })), + Effect.map( + ([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + }), + ), ); }, @@ -260,6 +267,15 @@ 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"))), + // 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/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index 526be38f7d..fddedbf605 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -4,6 +4,7 @@ import * as Result from "effect/Result"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; +import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; import { PullRequestProviderError, type ProviderChangeRequestDetail, @@ -34,6 +35,7 @@ import { const CAPABILITIES: PullRequestCapabilities = { diff: true, + viewedFiles: "environment", comment: true, actions: ["merge", "close", "reopen", "update-branch"], mergeMethods: ["merge", "squash", "rebase"], @@ -347,6 +349,17 @@ export const make = Effect.gen(function* () { }; }, ), + getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) { + const result = yield* request({ ...input, path: `${pullPath(input)}.diff` }); + if (result.stdoutTruncated) { + return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit."); + } + const revisions = new Map(parseDiffFileRevisions(result.stdout)); + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + return { revisions, complete: true }; + }), getDiff: (input) => request({ ...input, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 8416083061..a157c0d87a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2655,11 +2655,12 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ cwd: "/w", repository: "acme/web", host: "github.com", - number: 7, + number: 21, content: "rocket", reacted: true, }); @@ -2668,7 +2669,7 @@ layer("GitHubPullRequestCli.layer", (it) => { const lookup = callAt(0).args; expect(lookup).toContain("owner=acme"); expect(lookup).toContain("name=web"); - expect(lookup).toContain("number=7"); + expect(lookup).toContain("number=21"); // @effect-diagnostics-next-line preferSchemaOverJson:off const request = JSON.parse(callAt(1).stdin ?? "") as { query: string; @@ -2729,7 +2730,7 @@ layer("GitHubPullRequestCli.layer", (it) => { cwd: "/w", repository: "acme/web", host: "github.com", - number: 7, + number: 22, ...fields, }); @@ -2737,21 +2738,21 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* rewrite({ body: "A better description." }); yield* rewrite({ title: "Both", body: "at once." }); - // Each rewrite looks the pull request's node id up first, then mutates. + // One node id lookup for the pull request, then a mutation per rewrite. const variablesAt = (index: number) => (JSON.parse(callAt(index).stdin ?? "") as { variables: Record }).variables; expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); - expect(variablesAt(3)).toEqual({ + expect(variablesAt(2)).toEqual({ pullRequestId: "PR_kwDOA", body: "A better description.", }); - expect(variablesAt(5)).toEqual({ + expect(variablesAt(3)).toEqual({ pullRequestId: "PR_kwDOA", title: "Both", body: "at once.", }); // The reader's own words, so they travel the way every other body does. - expect(callAt(5).args.join(" ")).not.toContain("at once."); + expect(callAt(3).args.join(" ")).not.toContain("at once."); }), ); @@ -3476,4 +3477,252 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); }), ); + + 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: 23, + 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); + }), + ); + + it.effect("looks a pull request's node id up once, however often it is written to", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })), + ), + ) + .mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; + + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* cli.setPullRequestFilesViewed({ + ...pullRequest, + files: [{ path: "src/b.ts", viewed: true }], + }); + yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); + + // One lookup, then a mutation per write, every one of them addressed by the id it answered. + assert.strictEqual(mockedExecute.mock.calls.length, 4); + expect(callAt(0).args).toContain("number=24"); + const idSentAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) + .variables.pullRequestId; + expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]); + }), + ); + + it.effect("does not remember a node id lookup that failed", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const write = () => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 25, + files: [{ path: "src/a.ts", viewed: true }], + }); + + const error = yield* Effect.flip(write()); + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + + yield* write(); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + const idSentAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) + .variables.pullRequestId; + expect(idSentAt(2)).toEqual("PR_25"); + }), + ); + it.effect("keeps the pull request being ticked through, not the one looked up first", () => + Effect.gen(function* () { + // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is + // working down is the first thing evicted once a listing has walked a cache's worth of cold + // pull requests, and every press after that pays a round trip again. + // This block shares one cache, so these numbers are its own and it runs last. + const HOT = 9_000; + const lookupsOf = new Map(); + mockedExecute.mockImplementation((input) => { + const asked = input.args.find((arg) => arg.startsWith("number=")); + if (asked === undefined) return Effect.succeed(output("{}")); + const number = Number(asked.slice("number=".length)); + lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); + return Effect.succeed( + output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), + ); + }); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const tick = (number: number) => + cli.setPullRequestFilesViewed({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + files: [{ path: "src/a.ts", viewed: true }], + }); + + yield* tick(HOT); + // A cache's worth of cold pull requests, with the open one pressed in between each of them. + for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { + yield* tick(HOT + 1 + filled); + yield* tick(HOT); + } + + assert.strictEqual(lookupsOf.get(HOT), 1); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index d90d7378fa..3e815c74ef 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -9,6 +9,7 @@ import { type PullRequestAction, type PullRequestStackHead, type PullRequestActor, + type PullRequestFileViewed, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListState, @@ -33,10 +34,12 @@ import { ADD_REACTION_GRAPHQL_MUTATION, buildReviewSubmissionJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestHeadsJson, decodePullRequestListJson, decodePullRequestNodeIdJson, @@ -63,6 +66,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, @@ -365,6 +369,19 @@ 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; + +/** + * How many pull requests' node ids are remembered at once. A long-lived server sees far more of + * them than a reader ever has open, and least recently used rather than first in: a listing + * walking cold pull requests must not evict the review being ticked through. + */ +export const NODE_ID_CACHE_CAPACITY = 128; /** * Pages of review threads to follow before the conversation is reported as truncated. GitHub @@ -410,6 +427,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, { @@ -547,6 +570,29 @@ 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. + */ + 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; @@ -995,15 +1041,30 @@ export const make = Effect.gen(function* () { /** * The pull request's own node id, which is what a mutation against the pull request itself is * addressed by: a reaction on its description, or a rewrite of its words. + * + * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader + * ticking files viewed would otherwise pay a GraphQL round trip per press. */ + const nodeIds = new Map(); + const pullRequestNodeId = (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; readonly operation: string; - }) => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); + const key = `${input.host} ${owner}/${name} ${input.number}`; + const held = nodeIds.get(key); + if (held !== undefined) { + // Put back at the end on every hit, so what falls out is the pull request nobody has looked + // at rather than the one being ticked through: a run of cold reads would otherwise evict the + // open review and make it pay a round trip per press. + nodeIds.delete(key); + nodeIds.set(key, held); + return Effect.succeed(held); + } return graphqlRead({ cwd: input.cwd, host: input.host, @@ -1016,7 +1077,17 @@ export const make = Effect.gen(function* () { ], query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, decode: decodePullRequestNodeIdJson, - }); + }).pipe( + Effect.tap((nodeId) => + Effect.sync(() => { + if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) { + const oldest = nodeIds.keys().next().value; + if (oldest !== undefined) nodeIds.delete(oldest); + } + nodeIds.set(key, nodeId); + }), + ), + ); }; /** @@ -2363,6 +2434,56 @@ 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 }); + } + 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 }, + }), + ), + ); + }, + setReviewThreadResolution: (input) => graphql({ cwd: input.cwd, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 0b19b445ff..44f91e8b43 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -39,6 +39,7 @@ const CAPABILITIES: PullRequestCapabilities = { updateMethods: ["merge", "rebase"], search: true, reactions: true, + viewedFiles: "host", review: { inlineComment: true, reply: true, @@ -154,12 +155,14 @@ function withWorkflowApprovals( } const approvalChecks = runs .filter((run) => !representedRunIds.has(run.id)) - .map((run): PullRequestCheck => ({ - name: run.name, - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: run.url, - })); + .map( + (run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + }), + ); return [ ...checks, ...approvalChecks, @@ -384,35 +387,39 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...detail.pullRequest, - author: withAvatar(detail.pullRequest.author, new Map(), input.host), - checks: withWorkflowApprovals( - detail.pullRequest.checks, - detail.workflowApprovals.runs, - detail.workflowApprovals.unavailable, - ), - ...(detail.workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), - reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...viewerAccess, - canUpdateBranch: detail.comparison?.viewerCanUpdate === true, + Effect.map( + ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + author: withAvatar(detail.pullRequest.author, new Map(), input.host), + checks: withWorkflowApprovals( + detail.pullRequest.checks, + detail.workflowApprovals.runs, + detail.workflowApprovals.unavailable, + ), + ...(detail.workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, + }), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null + ? {} + : { behindBy: detail.comparison.behindBy }), }), - baseComparison: - detail.comparison === null || detail.comparison.behindBy === null - ? "unknown" - : detail.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), - })), + ), ), getChangeRequestActivity: (input) => @@ -445,72 +452,74 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ - author: withAvatar( - pullRequest.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - reviewers: reviewThreads.reviewers, - reactions: reviewThreads.reactions, - commits: (reviewThreads.commits.length > 0 - ? reviewThreads.commits - : pullRequest.commits - ).map((commit) => ({ - ...commit, - ...reviewThreads.commitStats.get(commit.oid), - authors: commit.authors?.map( - (author) => - withAvatar( - author, + Effect.map( + ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar( + pullRequest.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => + withAvatar( + author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar( + comment.author, reviewThreads.avatarsByLogin, input.host, reviewThreads.botLogins, - ) ?? author, - ), - })), - comments: [...pullRequest.comments, ...reviewThreads.comments] - .map((comment) => ({ - ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. - body: - comment.kind === "review" && - comment.reviewState?.toUpperCase() === "DISMISSED" && - rendersEmpty(comment.body) - ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) - : comment.body, - author: withAvatar( - comment.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. - reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], - })) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. - commentCount: pullRequest.comments.length + reviewThreads.commentCount, - commentsTruncated: reviewThreads.truncated, - reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - author: withAvatar( - comment.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), + ), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar( + comment.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + })), })), - })), - })), + }), + ), ), getReviewThreadComments: (input) => @@ -552,6 +561,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/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 014d91a027..daa85ee9eb 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1402,4 +1402,171 @@ 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"], + }); + + // 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 ?? "{}"); + expect(body).toMatchObject({ + variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, + }); + }), + ); + + it.effect("leaves the paths out when GitLab did not answer the blobs query", () => + 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" }, + }), + ), + ), + ); + // What GitLab says of a project the token cannot see. It is not the head having none of + // these files, and reading it that way would report every file the reader has cleared as + // changed on nothing worse than a permission. + mockedExecute.mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify({ data: { project: null } })), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/b.ts"], + }); + + expect([...revisions]).toEqual([]); + }), + ); + + 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) => { + const body = JSON.parse(request.stdin ?? "{}") as { + readonly variables: { readonly paths: ReadonlyArray }; + }; + return Effect.succeed( + output( + 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 60bf2c5574..5a07bb1ae3 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,21 @@ 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 answered as the empty revision, since the batch it was asked in was + * looked at; a batch GitLab did not answer for is left out instead. + */ + 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 @@ -1031,6 +1048,88 @@ 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 | null, 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 | null, 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 }).pipe( + Effect.map((page) => ({ paths, page })), + ), + { concurrency: 2 }, + ).pipe( + Effect.map((pages) => { + const revisions = new Map(); + for (const { paths, page } of pages) { + // A batch GitLab did not answer says nothing about its paths, so they are left + // out and the caller reads them as versions it could not learn, which leaves + // the marks on them alone. Filling them in as removed would report every file + // a reader has cleared as changed over a project the token cannot see. + if (page === null) continue; + for (const [path, oid] of page) revisions.set(path, oid); + // Within a batch that was answered, every path was looked for at the head, so + // one that is not there is one the merge request removed. Said as the empty + // revision, which is an answer the caller can compare against and keep. + for (const path of paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return revisions as ReadonlyMap; + }), + ); + }), + ); + const viewerUsername = (input: { readonly cwd: string }) => api({ cwd: input.cwd, path: "user" }).pipe( Effect.flatMap((result): Effect.Effect => { @@ -1066,6 +1165,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 46fce28842..17788910bc 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, @@ -147,22 +152,24 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ - ...mergeRequest, - mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. - baseComparison: - mergeRequest.divergedCommits === undefined - ? "unknown" - : mergeRequest.divergedCommits > 0 - ? "behind" - : "up-to-date", - ...(mergeRequest.divergedCommits === undefined - ? {} - : { behindBy: mergeRequest.divergedCommits }), - })), + Effect.map( + ([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + }), + ), ), getChangeRequestActivity: (input) => @@ -187,26 +194,28 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ + Effect.map( + ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ ...comment, reactions: awards.reactionsByNoteId.get(comment.id) ?? [], })), - })), - commits, - })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + })), + commits, + }), + ), ), // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge @@ -218,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 ba8d13908b..ccad5e3ceb 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -10,6 +10,7 @@ import type { PullRequestChecksState, PullRequestCheck, PullRequestComment, + PullRequestFileViewed, PullRequestCommit, PullRequestInvolvement, PullRequestLabel, @@ -258,6 +259,32 @@ 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; +} + +/** + * What version each of the asked-for files is at, on the change request's head. Opaque strings, + * compared only against one another, where the empty string is the answer for a file the change + * request deletes rather than a gap. A path is absent only where the read could not say, so a + * provider converts its host's own absences on the way here. + * + * The whole path a version travels, and the three senses of null along it, are in + * `docs/internals/pull-request-file-revisions.md`. + */ +export interface ProviderFileRevisions { + readonly revisions: ReadonlyMap; + /** + * Whether these are every file the change request carries rather than only the paths asked + * about. A host with no per-file version reads the whole change to answer for one file, and + * saying so is what keeps the next tick, naming a path nothing asked about before, from making + * it read the whole change again. False for a read cut short, which cannot speak past the cut. + */ + readonly complete?: boolean; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -429,6 +456,40 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is + * `"host"`, and read apart from the patch: this moves with every press rather than every push. + */ + readonly getFilesViewed?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. + * A host with no bulk form is still owed one round trip for the batch rather than one per file, + * since the point of gathering presses 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; + + /** + * 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 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 604967bc21..414fecbab1 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -17,8 +18,12 @@ 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 { ForgejoCli } from "../sourceControl/ForgejoCli.ts"; +import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import { PullRequestProviderError, type ProviderChangeRequest, @@ -27,6 +32,10 @@ import { import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; +import { + FILE_REVISIONS_CACHE_CAPACITY, + MAX_FILE_REVISION_PATHS, +} from "./pullRequestViewedFiles.ts"; function project(input: { readonly id: string; @@ -183,8 +192,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)({ @@ -200,12 +211,16 @@ function makeService(input: { Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), }), 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)), Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( Layer.provide(KeyValueStore.layerMemory), Layer.provide(NodeServices.layer), ), ), ), + (context) => Effect.provideContext(PullRequestService.make, context), ); } @@ -5008,6 +5023,1393 @@ it.effect("caches a small replacement after releasing a large diff", () => assert.strictEqual(reads, 2); }), ); +it.effect("keeps the diff cached across a file being ticked off", () => + Effect.gen(function* () { + let diffReads = 0; + let viewedReads = 0; + let state: "viewed" | "dismissed" = "viewed"; + 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: "host", + 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 }], + 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); + + state = "dismissed"; + yield* service.invalidate({ reference, filesViewedOnly: true }); + yield* service.diff(reference); + assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + assert.strictEqual(diffReads, 1); + assert.strictEqual(viewedReads, 3); + }), +); + +const environmentViewedProvider = ( + revisions: Map, + asked: Array>, + unreadable: ReadonlySet = new Set(), +) => + 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"), + // A merge confirms itself against the host before it says the change request landed. + getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + 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) => + unreadable.has(path) ? [] : [[path, revisions.get(path) ?? ""] as const], + ), + ), + }); + }, + }); + +const environmentViewedService = ( + revisions: Map, + asked: Array>, + unreadable: ReadonlySet = new Set(), +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [environmentViewedProvider(revisions, asked, unreadable)], + }); + +const GITLAB_REFERENCE = { + projectId: "p1" as ProjectId, + repository: "group/project", + number: 1, +}; + +it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () => + Effect.gen(function* () { + let alpha = "b485fe5"; + let truncated = false; + let diffReads = 0; + const provider = yield* ForgejoPullRequestProvider.make.pipe( + Effect.provide( + Layer.mock(ForgejoCli)({ + api: (input) => { + assert.strictEqual(input.host, "forge.example:3000"); + const viewer = input.path === "user"; + if (!viewer) { + assert.strictEqual(input.repository, "reviewer/project"); + assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff"); + diffReads++; + } + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: viewer + ? JSON.stringify({ login: "reviewer" }) + : truncated + ? `diff --git a/alpha.ts b/alpha.ts\nindex eb2d7c5..${alpha} 100644\n` + : [ + "diff --git a/alpha.ts b/alpha.ts", + `index eb2d7c5..${alpha} 100644`, + "diff --git a/beta.ts b/beta.ts", + "index b39e8b3..5851425 100644", + 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"', + "index ffd99ce..6dd9855 100644", + "diff --git a/deleted.txt b/deleted.txt", + "deleted file mode 100644", + "index 233f5c6..0000000", + "diff --git a/old.txt b/renamed.txt", + "similarity index 100%", + "rename from old.txt", + "rename to renamed.txt", + "", + ].join("\n"), + stderr: "", + stdoutTruncated: !viewer && truncated, + stderrTruncated: false, + }); + }, + }), + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "forgejo", + title: "on forgejo", + workspaceRoot: "/forgejo", + repository: "reviewer/project", + provider: "forgejo", + host: "forge.example:3000", + remoteUrl: "http://forge.example:3000/reviewer/project.git", + }), + ], + providers: [provider], + }); + const reference = { + projectId: "forgejo" as ProjectId, + repository: "reviewer/project", + host: "forge.example:3000", + number: 1, + }; + const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"]; + yield* service.setFilesViewed({ + ...reference, + files: paths.map((path) => ({ path, viewed: true })), + }); + assert.deepStrictEqual( + new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), + new Map(paths.map((path) => [path, "viewed"])), + ); + assert.strictEqual(diffReads, 1); + + alpha = "aabbccd"; + yield* service.invalidate({ reference }); + assert.deepStrictEqual( + new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), + new Map(paths.map((path) => [path, path === "alpha.ts" ? "dismissed" : "viewed"])), + ); + yield* service.setFilesViewed({ + ...reference, + files: [{ path: "beta.ts", viewed: false }], + }); + assert.isFalse( + (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"), + ); + + truncated = true; + yield* service.invalidate({ reference }); + yield* service.setFilesViewed({ + ...reference, + files: [{ path: "beta.ts", viewed: true }], + }); + truncated = false; + yield* service.invalidate({ reference }); + // A partial response must not stamp an empty revision and then dismiss the mark on recovery. + assert.deepStrictEqual( + (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"), + { path: "beta.ts", state: "viewed" }, + ); + }), +); + +it.effect("keeps hosted Forgejo marks with their repository instead of the serving checkout", () => + Effect.gen(function* () { + const projects = [ + project({ + id: "p1", + title: "first repository", + workspaceRoot: "/first", + repository: "reviewer/first", + provider: "forgejo", + host: "forge.example", + remoteUrl: "https://forge.example:3000/reviewer/first.git", + }), + ]; + const service = yield* makeService({ + projects, + providers: [ + { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" }, + ], + resolveHandle: ({ context }) => + Effect.succeed({ + context: { + ...context!, + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forge.example:3000" }, + }, + provider: undefined as never, + }), + }); + const first = { + projectId: "p1" as ProjectId, + host: "forge.example:3000", + repository: "reviewer/first", + number: 1, + }; + const second = { ...first, repository: "reviewer/second" }; + const files = [{ path: "same.ts", viewed: true }]; + yield* service.setFilesViewed({ ...first, files }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); + yield* service.setFilesViewed({ ...second, files }); + yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ + { path: "same.ts", state: "viewed" }, + ]); + + projects.push( + project({ + id: "p2", + title: "second repository", + workspaceRoot: "/second", + repository: "reviewer/second", + provider: "forgejo", + host: "forge.example", + remoteUrl: "https://forge.example:3000/reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); + projects.push( + project({ + id: "ssh", + title: "second repository over SSH", + workspaceRoot: "/ssh", + repository: "reviewer/second", + provider: "forgejo", + host: "ssh.forge.example", + remoteUrl: "git@ssh.forge.example:reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); + }), +); + +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, + // 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("holds what a whole-change answer carried, so the next tick reads nothing", () => + Effect.gen(function* () { + const asked: Array> = []; + const head = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(head, []), + // A host with no per-file version reads the whole change to answer for one file, which + // is what Bitbucket's patch is, and says as much. + getFileRevisions: (input) => { + asked.push(input.paths); + return Effect.succeed({ revisions: head, complete: true }); + }, + }, + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // A path nothing has asked about before, which is what every tick after the first names. Its + // version came back with the first answer, so there is nothing left to read it for. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + + assert.deepStrictEqual(asked, [["src/a.ts"]]); + 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" }, + ], + ); + + // Still only as fresh as the read it came from: past that window the press reads again rather + // than stamping a mark with a version the head may have moved off. + yield* TestClock.adjust("2 minutes"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.deepStrictEqual(asked, [["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)), + [ + { 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"]]); + }), +); + +it.effect("does not let a press about one file keep another file's version alive", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([ + ["src/a.ts", "blob-a"], + ["src/b.ts", "blob-b"], + ]); + const service = yield* environmentViewedService(revisions, asked); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* TestClock.adjust("40 seconds"); + // This press asks about its own file and carries the other one forward untouched. Counting + // the whole scope as heard from would put the first file's version back inside the window it + // had almost aged out of, and a reader working down a long diff renews it press after press. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); + + revisions.set("src/a.ts", "blob-a-again"); + yield* TestClock.adjust("30 seconds"); + yield* service.filesViewed(GITLAB_REFERENCE); + assert.strictEqual(asked.length, 3); + + yield* TestClock.adjust("20 seconds"); + const caught = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/a.ts", state: "dismissed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + }), +); + +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("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("keeps a file cleared that the press could not learn a version for", () => + Effect.gen(function* () { + // The press is the only moment a mark is given something to be measured against, and a host + // reading as much of a long change as it can manage does not always reach the file being + // ticked. Storing the empty version there reads as the head having nothing of the file, so the + // first read that does reach it reports the reader's own press back to them as work to do. + const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]); + const unreadable = new Set(["src/past-the-cut.ts"]); + const service = yield* environmentViewedService(revisions, [], unreadable); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/past-the-cut.ts", viewed: true }], + }); + unreadable.delete("src/past-the-cut.ts"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/past-the-cut.ts", state: "viewed" }, + ]); + }), +); + +it.effect("keeps the version it last heard when a later read of the head stops short", () => + Effect.gen(function* () { + const asked: Array> = []; + const revisions = new Map([["src/a.ts", "blob-a"]]); + const unreadable = new Set(); + const service = yield* environmentViewedService(revisions, asked, unreadable); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + + // The read behind the next answer has to stop before this file. Forgetting the version it was + // last seen at would put the badge the reader has already been shown back to cleared, over an + // answer that said nothing about the file either way. + unreadable.add("src/a.ts"); + yield* TestClock.adjust("90 seconds"); + yield* service.filesViewed(GITLAB_REFERENCE); + yield* TestClock.adjust("20 seconds"); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + +it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () => + Effect.gen(function* () { + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // A push nobody told this environment about. No single reference has moved, so the held + // answer goes only because the refresh is the reader asking for all of it to be read again. + revisions.set("src/a.ts", "blob-a-again"); + yield* service.invalidate({}); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + +it.effect("forgets what the head had of a marked file once a mutation moves the head", () => + Effect.gen(function* () { + const revisions = new Map([["src/a.ts", "blob-a"]]); + const service = yield* environmentViewedService(revisions, []); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Merging moves the head under the mark, and nobody asks for the refresh: the mutation is + // the thing that knows, so it drops what it was holding rather than waiting to be told. + revisions.set("src/a.ts", "blob-a-again"); + yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "dismissed" }, + ]); + }), +); + +it.effect("still reports its own marks when the host will not say what the head has", () => + Effect.gen(function* () { + let answering = true; + 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) => + answering + ? Effect.succeed({ + revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)), + }) + : Effect.fail(requestFailed), + }), + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + answering = false; + yield* service.invalidate({ reference: GITLAB_REFERENCE }); + + // The rows are this environment's own. A rate limit or a signed-out CLI costs them the + // staleness they would have carried, not the reader's whole record of what they have read. + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.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, []); + }), +); + +/** Azure, whose selector is a bare repository name, backed by this environment's own marks. */ +const azureViewedService = (projects: ReadonlyArray) => + makeService({ + projects, + 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 AZURE_PAIR = [ + 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", + }), +]; + +const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; +const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; + +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* azureViewedService(AZURE_PAIR); + + yield* service.setFilesViewed({ + ...AZURE_PLATFORM, + files: [{ path: "src/a.ts", viewed: true }], + }); + + assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_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"]]), []); + + 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("bounds the paths one change request's held revisions carry", () => + Effect.gen(function* () { + // The cache's count bounds how many change requests are held, not what any one of them holds: + // a reader ticking a wide change request renews the same entry on every press and adds a path + // to it each time. A press carries at most half the cap, so going one past it takes three. + const asked: Array> = []; + const service = yield* environmentViewedService(new Map(), asked); + const batch = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ + path: `${prefix}/${String(index).padStart(4, "0")}.ts`, + viewed: true, + })); + const press = (prefix: string, count: number) => + service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); + + yield* press("a", MAX_FILE_REVISION_PATHS / 2); + yield* press("b", MAX_FILE_REVISION_PATHS / 2); + yield* press("c", 1); + const pressed = asked.length; + + // The marks a read carries come first by path, so this one covers the earliest batch, which + // is where the paths asked about longest ago are. Held short of them the entry no longer + // answers the read, and the host is asked rather than the reader being told a version that + // nothing holds any more. + yield* service.filesViewed(GITLAB_REFERENCE); + + assert.strictEqual(asked.length, pressed + 1); + assert.ok(asked.at(-1)?.includes("a/0000.ts")); + }), +); + +it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () => + Effect.gen(function* () { + // A host with no per-file version answers with the whole change, which on a wide review + // carries more paths than one entry holds. What the trim reaches has to be the paths the + // answer threw in rather than the ones the reader ticked: a mark stored with no baseline + // reports viewed however far the head moves off it. + const head = new Map( + Array.from( + { length: MAX_FILE_REVISION_PATHS + 178 }, + (_, index) => + [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const, + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(head, []), + getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }), + }, + ], + }); + const ticked = ["src/f0000.ts", "src/f0500.ts"]; + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: ticked.map((path) => ({ path, viewed: true })), + }); + for (const path of ticked) head.set(path, "blob-moved"); + // Past the stale window, so the read is answered by the host rather than from what the press + // heard. + yield* TestClock.adjust("11 minutes"); + const marked = yield* service.filesViewed(GITLAB_REFERENCE); + + assert.deepStrictEqual( + [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), + [ + { path: "src/f0000.ts", state: "dismissed" }, + { path: "src/f0500.ts", state: "dismissed" }, + ], + ); + }), +); + +it.effect("keeps the change request being ticked through, not the one pressed first", () => + Effect.gen(function* () { + // Ordered by insertion alone a hit does not renew its entry, so the review a reader is + // working down is the first thing dropped once a cache's worth of other change requests have + // been pressed, and the next press on it pays a host read for a version already held. + const asked: Array> = []; + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); + const press = (number: number) => + service.setFilesViewed({ + ...GITLAB_REFERENCE, + number, + files: [{ path: "src/a.ts", viewed: true }], + }); + + yield* press(1); + // A cache's worth of other change requests, with the open one pressed in between each. + for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { + yield* press(2 + filled); + yield* press(1); + } + + assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); + }), +); + +/** The environment-backed fixture with its own answer to who the reader is. */ +const environmentViewedServiceWithViewer = ( + revisions: Map, + getViewer: PullRequestProviderApi["getViewer"], +) => + makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], + }); + +it.effect("keeps one reader's marks on a host that names nobody", () => + Effect.gen(function* () { + const service = yield* environmentViewedServiceWithViewer( + new Map([["src/a.ts", "blob-a"]]), + () => Effect.succeed(""), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + }), +); + +it.effect("puts a listing and a press for one host on a single viewer lookup", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.gen(function* () { + viewerLookups += 1; + // Suspends before answering, as a subprocess would, so both callers are in flight + // at once rather than the second finding the first has already answered. + yield* Effect.yieldNow; + return "bilal"; + }), + }, + ], + }); + + // What a cold page load does: read the listing and the reader's own marks at the same time. + // Nothing about which of them asked is in the lookup's key, so they wait on one CLI between + // them rather than starting one each. + yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { + concurrency: 2, + }); + + assert.strictEqual(viewerLookups, 1); + }), +); + +it.effect("carries a bounded number of its own marks and says it held more", () => + Effect.gen(function* () { + const asked: Array> = []; + const paths = Array.from( + { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 }, + (_, at) => `src/f${String(at).padStart(4, "0")}.ts`, + ); + const service = yield* environmentViewedService( + new Map(paths.map((path) => [path, "blob"] as const)), + asked, + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: paths.map((path) => ({ path, viewed: true })), + }); + const read = yield* service.filesViewed(GITLAB_REFERENCE); + + // Every mark read is a path held in a set and a map for as long as the caller holds the read, + // per scope it is holding, so the rows are bounded rather than however many a reader has ever + // ticked. The reader is told the count is short rather than shown a quietly clipped list. + assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS); + assert.strictEqual(read.truncated, true); + }), +); + +it.effect("records a press while the host is backing off", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.sync(() => { + viewerLookups += 1; + return "bilal"; + }), + // Backing off for the hour, so the pause outlives the ten minutes who is signed in is + // held for and the second press has to ask again while it is on. + getFileRevisions: () => + Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getFileRevisions", + reason: "rate-limited", + detail: "API rate limit exceeded.", + retryAt: 60 * 60 * 1_000, + }), + ), + }, + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + yield* TestClock.adjust("11 minutes"); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + + // Nothing about a host holding its reads off says the reader did not press these, and these + // rows are this environment's own. They keep no baseline, because none was read, so they hold + // until they are pressed again rather than reporting a staleness nobody looked up. + assert.deepStrictEqual( + [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) => + left.path.localeCompare(right.path), + ), + [ + { path: "src/a.ts", state: "viewed" }, + { path: "src/b.ts", state: "viewed" }, + ], + ); + assert.strictEqual(viewerLookups, 2); + }), +); + +it.effect("asks who is reading through a pause only for the press that is waiting on it", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.sync(() => { + viewerLookups += 1; + return "bilal"; + }), + listChangeRequests: () => + Effect.succeed({ items: [], truncated: false, continues: true }), + // Backing off for the hour, so the pause outlives the ten minutes who is signed in is + // held for. + getFileRevisions: () => + Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getFileRevisions", + reason: "rate-limited", + detail: "API rate limit exceeded.", + retryAt: 60 * 60 * 1_000, + }), + ), + }, + ], + }); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + assert.strictEqual(viewerLookups, 1); + yield* TestClock.adjust("11 minutes"); + + // A listing is not the reader waiting on this lookup, and a failed one is held nowhere, so + // letting it through would spawn the host's CLI on every refresh for as long as the pause + // lasts and re-extend it each time. + const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + assert.strictEqual(listed._tag, "PullRequestOperationError"); + assert.strictEqual(viewerLookups, 1); + + // The press is bounded by what the reader does, and its rows are keyed by who they are, so + // it is asked rather than refused. + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }); + assert.strictEqual(viewerLookups, 2); + }), +); + +it.effect("does not ask a paused host who is reading again after the ask failed", () => + Effect.gen(function* () { + let viewerLookups = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => + Effect.suspend(() => { + viewerLookups += 1; + return Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getViewer", + reason: "failed", + detail: "glab exited with status 1", + }), + ); + }), + listChangeRequests: () => + Effect.succeed({ items: [], truncated: false, continues: true }), + runAction: () => + Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "runAction", + reason: "rate-limited", + detail: "API rate limit exceeded.", + retryAt: 60 * 60 * 1_000, + }), + ), + }, + ], + }); + + yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); + assert.strictEqual(viewerLookups, 1); + yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" })); + + // A failed lookup is held nowhere, so a background read let through the pause would spawn the + // host's CLI on every refresh for as long as the pause lasted, and re-extend it each time. + yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + yield* TestClock.adjust("11 minutes"); + yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + assert.strictEqual(viewerLookups, 1); + }), +); + +it.effect("refuses the marks when the host could not be asked who is reading", () => + Effect.gen(function* () { + let answering = true; + const service = yield* environmentViewedServiceWithViewer( + new Map([["src/a.ts", "blob-a"]]), + () => + answering + ? Effect.succeed("bilal") + : Effect.fail( + new PullRequestProviderError({ + provider: "gitlab", + operation: "getViewer", + reason: "failed", + detail: "glab exited with status 1", + }), + ), + ); + + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + // Who is signed in is held for ten minutes, so the lookup has to come round again before a + // failing CLI can reach the read at all. + answering = false; + yield* TestClock.adjust("11 minutes"); + + // Answering these from the unnamed reader's rows would show the reader none of their own + // ticks, and file the next press where the recovered CLI will never look for it again. + const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); + const write = yield* Effect.flip( + service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true }], + }), + ); + assert.strictEqual(read._tag, "PullRequestOperationError"); + assert.strictEqual(write._tag, "PullRequestOperationError"); + + answering = true; + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed" }, + ]); + }), +); + +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"); + }), +); + it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => Effect.gen(function* () { const seen: string[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8752adfa05..a1326c3b65 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -3,6 +3,7 @@ import { isSshRemoteUrl, sourceControlRepositorySelector, } from "@t3tools/shared/sourceControl"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -35,6 +36,7 @@ import { type PullRequestDiffFileContentsResult, type PullRequestDiffStat, type PullRequestDiffInput, + type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, type PullRequestListEntry, @@ -52,6 +54,7 @@ import { type PullRequestReviewerRequestInput, type PullRequestLabelCandidateList, type PullRequestLabelChangeInput, + type PullRequestSetFilesViewedInput, type PullRequestSubmitReviewInput, PullRequestStack, PullRequestSummary, @@ -67,6 +70,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; 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 { @@ -77,6 +81,7 @@ import { } from "./PullRequestProvider.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; +import * as ViewedFiles from "./pullRequestViewedFiles.ts"; export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; @@ -125,6 +130,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); /** 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. */ @@ -140,6 +151,7 @@ const DIFF_CACHE_CAPACITY = 128; const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024; const canCacheDiff = (value: PullRequestDiffResult) => value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES; +const FILES_VIEWED_CACHE_CAPACITY = 128; const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -185,6 +197,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; @@ -261,13 +279,18 @@ const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to as const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; /** A project this page can read: its remote is on a host with an implementation. */ -interface SupportedProject { +export interface SupportedProject { readonly cursorKey: string; readonly project: OrchestrationProjectShell; readonly api: PullRequestProviderApi; readonly repository: string; /** The host the repository lives on, which is the account boundary rather than the kind. */ readonly host: string; + /** + * The identity's canonical key, which is what this environment's own records are keyed by. + * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. + */ + readonly remote: string; } /** @@ -433,6 +456,7 @@ function withRateLimitBackoff( api: PullRequestProviderApi, host: string, limits: SourceControlRateLimit.SourceControlRateLimit["Service"], + allowPausedViewer = false, ): PullRequestProviderApi { const key = { provider: api.kind, host }; const protect = ( @@ -484,7 +508,7 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, - getViewer: wrap("getViewer", api.getViewer), + getViewer: wrap("getViewer", api.getViewer, allowPausedViewer), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -516,6 +540,15 @@ 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) }), + ...(api.getFileRevisions === undefined + ? {} + : { getFileRevisions: wrap("getFileRevisions", api.getFileRevisions) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -555,6 +588,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 readCache = yield* PullRequestReadCache.PullRequestReadCache; const refineUnknownProjectKinds = ( @@ -727,6 +761,10 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, + remote: + kind === "azure-devops" + ? identity.canonicalKey + : normalizeGitRemoteUrl(`https://${host}/${repository}`), }); } return { supported, unimplemented, viewerRoots }; @@ -793,7 +831,14 @@ export const make = Effect.gen(function* () { ); } return Effect.succeed( - route.api.kind === "azure-devops" ? route : { ...route, repository }, + route.api.kind === "azure-devops" || + route.repository.toLowerCase() === repository.toLowerCase() + ? route + : { + ...route, + repository, + remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + }, ); }), ); @@ -875,16 +920,17 @@ export const make = Effect.gen(function* () { const viewersByHost = new Map(); const viewerFlights = yield* Cache.makeWith( (key: string): Effect.Effect => { - const [host, kind, roots] = JSON.parse(key) as [ + const [host, kind, roots, allowPaused] = JSON.parse(key) as [ string, SourceControlProviderKind, ReadonlyArray, + boolean, ]; const registered = registry.get(kind); if (registered === null) { return Effect.die(new Error(`Missing pull request provider: ${kind}`)); } - const api = withRateLimitBackoff(registered, host, rateLimits); + const api = withRateLimitBackoff(registered, host, rateLimits, allowPaused); return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe( Effect.map((viewer) => ({ host, @@ -917,6 +963,7 @@ export const make = Effect.gen(function* () { const resolveViewers = ( projects: ReadonlyArray, viewerRoots: WorkspaceProjects["viewerRoots"], + options?: { readonly allowPaused: boolean }, ) => Effect.forEach( [...new Set(projects.map(({ host }) => host))], @@ -932,8 +979,40 @@ export const make = Effect.gen(function* () { // unreadable worktree would otherwise report the whole host as signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); - const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); - return Cache.get(viewerFlights, key); + // A press shares the ordinary lookup while the host is available. Only a paused host + // needs a separate flight whose own guard permits this reader-initiated lookup. + const key = (allowPaused: boolean) => + JSON.stringify([host, api.kind, [...new Set(roots)].sort(), allowPaused]); + if (options?.allowPaused === true) { + return rateLimits.check({ provider: api.kind, host }).pipe( + Effect.matchEffect({ + onFailure: () => Cache.get(viewerFlights, key(true)), + onSuccess: () => Cache.get(viewerFlights, key(false)), + }), + ); + } + // The pause is checked here rather than inside the lookup, so that it holds back the + // callers nobody is waiting on without splitting the flight they share with a press. + // A failed lookup is held nowhere, so letting a background read through would spawn + // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. + return rateLimits.check({ provider: api.kind, host }).pipe( + Effect.flatMap(() => Cache.get(viewerFlights, key(false))), + Effect.catch((error) => + Effect.succeed({ + host, + kind: api.kind, + viewer: null, + error: new PullRequestProviderError({ + provider: api.kind, + operation: "getViewer", + reason: "rate-limited", + detail: error.detail, + retryAt: error.retryAt, + cause: error, + }), + }), + ), + ); }), { concurrency: REPOSITORY_CONCURRENCY }, ); @@ -1182,19 +1261,21 @@ export const make = Effect.gen(function* () { }), // One unreachable repository must not blank the page. A host-level failure is // already reported through `providers`, so it degrades the same way here. - Effect.orElseSucceed((): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - })), + Effect.orElseSucceed( + (): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + }), + ), ); } }; @@ -1377,41 +1458,43 @@ export const make = Effect.gen(function* () { return read.pipe( Effect.mapError(toPullRequestError("summary")), observeRead, - Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ - provider: project.api.kind, - projectId: project.project.id, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - url: changeRequest.url, - state: changeRequest.state, - headBranch: changeRequest.headBranch, - baseBranch: changeRequest.baseBranch, - closedAt: changeRequest.closedAt ?? null, - mergedAt: changeRequest.mergedAt ?? null, - updatedAt: changeRequest.updatedAt, - observedAt, - ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), - ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), - ...(changeRequest.additions === undefined - ? {} - : { additions: changeRequest.additions }), - ...(changeRequest.deletions === undefined - ? {} - : { deletions: changeRequest.deletions }), - ...(changeRequest.changedFiles === undefined - ? {} - : { changedFiles: changeRequest.changedFiles }), - ...(changeRequest.reviewDecision === undefined - ? {} - : { reviewDecision: changeRequest.reviewDecision }), - ...(changeRequest.checksState === undefined - ? {} - : { checksState: changeRequest.checksState }), - ...(changeRequest.mergeability === undefined - ? {} - : { mergeability: changeRequest.mergeability }), - })), + Effect.map( + ({ value: changeRequest, observedAt }): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, + updatedAt: changeRequest.updatedAt, + observedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), + }), + ), ); }), ); @@ -1466,54 +1549,56 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ).pipe( - Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ - provider: project.api.kind, - capabilities: project.api.capabilities, - projectId: project.project.id, - projectTitle: project.project.title, - workspaceRoot: project.project.workspaceRoot, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - body: changeRequest.body, - url: changeRequest.url, - author: changeRequest.author, - state: changeRequest.state, - isDraft: changeRequest.isDraft, - mergeability: changeRequest.mergeability, - additions: changeRequest.additions, - deletions: changeRequest.deletions, - changedFiles: changeRequest.changedFiles, - headBranch: changeRequest.headBranch, - ...(changeRequest.headRepositoryNameWithOwner === undefined - ? {} - : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), - baseBranch: changeRequest.baseBranch, - createdAt: changeRequest.createdAt, - updatedAt: changeRequest.updatedAt, - observedAt, - mergedAt: changeRequest.mergedAt, - closedAt: changeRequest.closedAt, - reviewers: changeRequest.reviewers, - labels: changeRequest.labels, - checks: changeRequest.checks, - mergeCapabilities: changeRequest.mergeCapabilities, - viewerPermissions: changeRequest.viewerPermissions, - ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), - ...(changeRequest.baseComparison === undefined - ? {} - : { baseComparison: changeRequest.baseComparison }), - ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), - ...(changeRequest.autoMergeEnabled === undefined - ? {} - : { autoMergeEnabled: changeRequest.autoMergeEnabled }), - ...(changeRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: changeRequest.autoMergeMethod }), - ...(changeRequest.workflowApprovalsRequired === undefined - ? {} - : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), - })), + Effect.map( + ([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + ...(changeRequest.headRepositoryNameWithOwner === undefined + ? {} + : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + observedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + ...(changeRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.workflowApprovalsRequired === undefined + ? {} + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), + }), + ), ), ), ); @@ -1530,16 +1615,18 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("activity")), - Effect.map((activity): PullRequestActivity => ({ - ...(activity.author === undefined ? {} : { author: activity.author }), - ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), - comments: activity.comments, - commentCount: activity.commentCount, - commentsTruncated: activity.commentsTruncated, - reviewThreads: activity.reviewThreads, - commits: activity.commits, - ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), - })), + Effect.map( + (activity): PullRequestActivity => ({ + ...(activity.author === undefined ? {} : { author: activity.author }), + ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), + comments: activity.comments, + commentCount: activity.commentCount, + commentsTruncated: activity.commentsTruncated, + reviewThreads: activity.reviewThreads, + commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + }), + ), ), ), ); @@ -1616,6 +1703,43 @@ 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); + + /** + * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that + * failed is refused rather than answered as the unnamed reader: a momentarily signed-out CLI + * would otherwise hide every tick this reader has made and file the next press under rows that + * are orphaned once it recovers. The reader is waiting on every one of these paths, so the + * lookup is let through a host's backoff rather than turning a pause into a refusal, and only + * here, where the bypass is bounded by what the reader does. + */ + const requiredViewerOf = ( + project: SupportedProject, + operation: string, + ): Effect.Effect => + resolveViewers([project], new Map(), { allowPaused: true }).pipe( + Effect.flatMap(([resolved]) => { + const error = resolved?.error ?? null; + return error === null + ? Effect.succeed(resolved?.viewer ?? null) + : Effect.fail(toPullRequestError(operation)(error)); + }), + ); + + const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) => + canonicalRef(input).pipe( + Effect.flatMap((ref) => + viewedFiles.setFilesViewed(input).pipe( + // 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(ref))), + ), + ), + ); + const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2140,22 +2264,23 @@ export const make = Effect.gen(function* () { ); } return viewerPermissionsOf(project, input, "setLabels").pipe( - Effect.flatMap((viewer): Effect.Effect => - viewer.labels === false - ? Effect.fail( - new PullRequestOperationError({ - operation: "setLabels", - detail: LABEL_CHANGE_REFUSAL, - }), - ) - : change({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - labels: input.labels, - applied: input.applied, - }).pipe(Effect.mapError(toPullRequestError("setLabels"))), + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels === false + ? Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : change({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }).pipe(Effect.mapError(toPullRequestError("setLabels"))), ), ); }), @@ -2248,9 +2373,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. @@ -2405,14 +2527,34 @@ export const make = Effect.gen(function* () { if (oldest !== undefined) recentStats.delete(oldest); } }; - 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); + + /** Bumped by a whole-workspace refresh, the one drop no single reference's epoch covers. */ + let everyFileRevisionEpoch = 0; + // Built after the epochs because it reads two of them: taken as an argument any higher, + // `refEpoch` would be read while its `const` was still in its dead zone and this would throw. + const viewedFiles = ViewedFiles.make({ + filesViewedStore, + requireProject, + requiredViewerOf, + toPullRequestError, + runFork, + refEpoch, + fileRevisionsEpoch: () => everyFileRevisionEpoch, + }); /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ const filtersOfKey = ( @@ -2716,6 +2858,25 @@ export const make = Effect.gen(function* () { return staleDiff(key, read); }; + const filesViewedCache = yield* Cache.makeWith( + (key: string) => { + const [referenceKey] = JSON.parse(key) as [string, number]; + return viewedFiles.filesViewed(refOfCacheKey(referenceKey)); + }, + { + capacity: FILES_VIEWED_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), + }, + ); + // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, + // so a reference keyed as the client spelled it would never see a refresh or a press. + const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => + canonicalRef(input).pipe( + Effect.flatMap((ref) => + Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), + ), + ); + const listStatsCache = yield* Cache.makeWith( (key: string) => { const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number, number]>]; @@ -2775,6 +2936,14 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; + if (input.filesViewedOnly === true) { + return reference === undefined + ? Cache.invalidateAll(filesViewedCache) + : canonicalRef(reference).pipe( + Effect.flatMap((ref) => Effect.sync(() => bumpFilesViewedEpoch(ref))), + Effect.ignore, + ); + } if (reference !== undefined) { return canonicalRef(reference).pipe( Effect.flatMap((ref) => @@ -2787,6 +2956,7 @@ export const make = Effect.gen(function* () { } return Effect.sync(() => { listingsEpoch = ++epochCounter; + everyFileRevisionEpoch = ++epochCounter; viewersByHost.clear(); }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); }; @@ -2888,6 +3058,8 @@ export const make = Effect.gen(function* () { threadComments, diff: canonicalCached(diff), diffFileContents, + filesViewed, + setFilesViewed, runAction: runActionAndInvalidate, update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), diff --git a/apps/server/src/pullRequest/azureDevOpsDiff.test.ts b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts new file mode 100644 index 0000000000..30d5757551 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.test.ts @@ -0,0 +1,459 @@ +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import { describe, expect, it } from "vite-plus/test"; + +import { + azureDevOpsFilePatch, + azureDevOpsUnreadableFilePatch, + byteLength, + formatAzureDevOpsDiffCursor, + MAX_FILE_DIFF_EDITS, + 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, + }; +} + +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: texts("one\ntwo\nthree\n", "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: texts("", "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: texts("gone\n", ""), + }); + + 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: texts("one\r\ntwo\r\n", "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: texts("same\n", "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: texts("PNG\u0000old", "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: texts("a\n".repeat(400_000), "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("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"), + ); + }); + + const lineRange = (count: number, prefix: string) => + Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); + + it("lists a file too far apart to diff without its hunks", () => { + // Sharing no line at all costs one edit per line on each side, so this pair is twice the + // ceiling apart. Left to itself the search costs about the square of that and would hold the + // whole server, every websocket client with it, while it worked out a patch nobody reads. + // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance + // rather than a proportion, so a long file reaches it having changed in one corner, and that + // corner would be buried in a wall of red and green. + const lines = (prefix: string) => + Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); + const patch = azureDevOpsFilePatch({ + change: change({ path: "generated.ts", oldPath: "generated.ts" }), + texts: texts(`${lines("old")}\n`, `${lines("new")}\n`), + }); + + expect(patch.truncated).toBe(true); + // And it says the search was given up on, because the reader of a run of files is meant to + // stop rather than spend that work 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("writes out a wholly new file however many lines it has", () => { + // Nothing on the old side means there was no edit distance to search out, so this is the + // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the + // shape this used to lose: inside the byte ceiling that gates every file, and well past every + // bound a two-sided file answers to, none of which is protecting against anything here. + const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(15_000); + expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); + // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of + // the middle. + expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); + }); + + it("keeps a wholly new file too heavy to write out listed without its hunks", () => { + // Every line carries a marker, so a side of very short lines answers with up to twice its own + // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh + // about 600KB written out, more than twice what a whole slice may carry. There is no smaller + // true patch for a creation to fall back to, so it is listed without its hunks, the same as a + // side too big to read at all, rather than sent at a size the slice budget exists to prevent. + const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), + texts: texts("", contents), + }); + + expect(byteLength(contents)).toBeLessThan(512 * 1024); + expect(patch.truncated).toBe(true); + expect(patch.abandoned).toBe(false); + // It still cost the walk over its lines, which is what the slice is charged for. + expect(patch.edits).toBe(200_000); + expect(patch.section).toBe( + [ + "diff --git a/bundle.min.js b/bundle.min.js", + "new file mode 100644", + "--- /dev/null", + "+++ b/bundle.min.js", + "", + ].join("\n"), + ); + }); + + it("writes out a wholly deleted file however many lines it had", () => { + const contents = `${lineRange(20_000, "line")}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }), + texts: texts(contents, ""), + }); + + expect(patch.truncated).toBe(false); + expect(patch.abandoned).toBe(false); + expect(patch.edits).toBe(20_000); + expect(patch.section).toContain("@@ -1,20000 +0,0 @@"); + expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000); + expect(patch.section.match(/^-/gmu)).toHaveLength(20_001); + }); + + it("gives an empty new file no hunk to read", () => { + // A file with nothing on either side has no lines to claim were replaced, and git writes it + // as a header alone. + const patch = azureDevOpsFilePatch({ + change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), + texts: texts("", ""), + }); + + expect(patch.edits).toBe(0); + expect(patch.section).not.toContain("@@"); + }); + + it("marks a wholly new file whose last line has no newline after it", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }), + texts: texts("", "one\ntwo"), + }); + + expect(patch.section).toBe( + [ + "diff --git a/NOTES.md b/NOTES.md", + "new file mode 100644", + "--- /dev/null", + "+++ b/NOTES.md", + "@@ -0,0 +1,2 @@", + "+one", + "+two", + "\\ No newline at end of file", + "", + ].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("counts what the diff worked out, which is what the file cost to diff", () => { + // The caller spends a budget of these across a slice, so they have to be the edits the search + // actually made: one line replaced is a removal and an addition, and the three lines of + // context around them cost nothing. + const patch = azureDevOpsFilePatch({ + change: change(), + texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), + }); + + expect(patch.edits).toBe(2); + }); + + it("counts nothing for a file it never diffed", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo.png", oldPath: "logo.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.edits).toBe(0); + }); + + it("lists a file whose hunks outweigh its sides without them", () => { + // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the + // patch carries both sides in full with three lines of context around each hunk, so the + // section comes out heavier than either side was. What one file weighs is what a slice's + // budget is spent in, so the edit ceiling alone does not bound this. + const line = `${"a".repeat(400 * 1024)}\n`; + const patch = azureDevOpsFilePatch({ + change: change({ path: "min.js", oldPath: "min.js" }), + texts: texts(line, `${"b".repeat(400 * 1024)}\n`), + }); + + expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS); + expect(patch.truncated).toBe(true); + expect(patch.section).toBe( + ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"), + ); + expect(byteLength(patch.section)).toBeLessThan(byteLength(line)); + }); + + it("marks a file that does not end in a newline, as git does", () => { + const patch = azureDevOpsFilePatch({ + change: change(), + 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 file Azure names something a patch header cannot carry plainly", () => { + it("writes each side as git's quoted form, the side letter inside the quotes", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }), + texts: texts("one\n", "two\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"', + '--- "a/notes\\treadme.md"', + '+++ "b/notes\\treadme.md"', + "@@ -1 +1 @@", + "-one", + "+two", + "", + ].join("\n"), + ); + }); + + it("keeps a name holding a newline on the one header line it belongs to", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }), + texts: texts("one\n", "two\n"), + }); + + // Written as itself the name would start a line of its own, and a reader would take what + // followed for a header the patch never had. + expect(patch.section.split("\n").slice(0, 3)).toEqual([ + 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', + '--- "a/line\\nfile.txt"', + '+++ "b/line\\nfile.txt"', + ]); + }); + + it("quotes the names a rename states, which carry no side letter", () => { + const patch = azureDevOpsFilePatch({ + change: change({ + path: "docs/new\tname.md", + oldPath: "docs/old\tname.md", + changeKind: "rename-pure", + }), + texts: texts("same\n", "same\n"), + }); + + expect(patch.section).toBe( + [ + 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"', + 'rename from "docs/old\\tname.md"', + 'rename to "docs/new\\tname.md"', + '--- "a/docs/old\\tname.md"', + '+++ "b/docs/new\\tname.md"', + "", + ].join("\n"), + ); + }); + + it("quotes the sides of the one line a binary file gets", () => { + const patch = azureDevOpsFilePatch({ + change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }), + texts: texts("PNG\u0000old", "PNG\u0000new"), + }); + + expect(patch.section).toContain( + 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ', + ); + }); + + it("hands a reader of the header back the name Azure gave", () => { + const path = 'every\t\n"kind"\\of.md'; + const patch = azureDevOpsFilePatch({ + change: change({ path, oldPath: path }), + texts: texts("one\n", "two\n"), + }); + const [header, oldLine, newLine] = patch.section.split("\n"); + + expect(header).not.toContain("\t"); + expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`); + expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`); + }); +}); + +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(); + } + }); + + it("refuses a half it did not write rather than reading it as the first file", () => { + // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a + // position, and the read would resume against an iteration the client never saw instead of + // starting again from the latest one. + for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { + 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 0000000000..aa5323c32f --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsDiff.ts @@ -0,0 +1,273 @@ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +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 a push landing mid-read would renumber the list under a cursor that + * did not also pin the iteration. + */ +export interface AzureDevOpsDiffCursor { + readonly iterationId: number; + readonly fileIndex: number; +} + +const CURSOR_SEPARATOR = ":"; + +/** + * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as + * three. + */ +const CURSOR_COMPONENT = /^\d+$/; + +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; + if (iteration === undefined || file === undefined) return null; + if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) 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; + /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ + readonly binary: boolean; +} + +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, having spent the whole edit budget, so a caller reading a + * run of files stops here rather than paying that again for each one behind it. + */ + readonly abandoned: boolean; + /** + * Lines added or removed, which is the edit distance the diff had to search out, and what a + * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. + */ + readonly edits: number; +} + +/** + * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather + * than a patch, so an oversize file is paid for twice over before anything can be diffed. + */ +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 far apart one file's two sides may be before it is listed without its hunks. The line diff + * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could + * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same + * way on every machine. + */ +export const MAX_FILE_DIFF_EDITS = 2_000; + +/** + * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than + * this is what decides a patch's shape: a timeout that fires would slice the same change one way + * here and another on a busier host. + */ +const MAX_FILE_DIFF_MILLIS = 2_000; + +/** How much diff work one slice does before the rest is left for the next request. */ +export const MAX_DIFF_SLICE_EDITS = 6_000; + +/** How much patch one slice carries before the rest is left for the next request. */ +export const MAX_DIFF_SLICE_BYTES = 256 * 1024; + +/** + * How many files one slice carries however little each one weighs. A binary, oversize, or + * unreadable entry is just a header, so neither budget above stops a run of thousands of them. + */ +export const MAX_DIFF_SLICE_FILES = 300; + +/** Git's own note for a side whose last line has no newline after it. */ +const NO_NEWLINE_MARKER = "\\ No newline at end of file"; + +/** A text's lines, without the empty one that a trailing newline leaves behind a split. */ +function contentLines(contents: string): ReadonlyArray { + if (contents === "") return []; + const lines = contents.split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + +/** 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"); +} + +/** + * 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 intended size. + */ +export 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. + */ +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. Names are quoted the way git quotes them, prefix inside the + * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either + * would be truncated and its viewed mark would land on the wrong file. + */ +function patchHeader(change: AzureDevOpsChangeEntry): string { + const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${change.path}`); + const lines = [`diff --git ${oldSide} ${newSide}`]; + 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 ${quoteGitPatchPath(change.oldPath)}`, + `rename to ${quoteGitPatchPath(change.path)}`, + ); + } + lines.push( + `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, + `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, + ); + return lines.join("\n"); +} + +/** + * A file written out as wholly replaced: every old line gone, every new line arrived, in one + * hunk, which needs no edit-distance search at all. + */ +function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { + const oldLines = contentLines(texts.oldContents); + const newLines = contentLines(texts.newContents); + const noNewline = (contents: string, lines: ReadonlyArray) => + lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; + return [ + header, + `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, + ...oldLines.map((line) => `-${line}`), + ...noNewline(texts.oldContents, oldLines), + ...newLines.map((line) => `+${line}`), + ...noNewline(texts.newContents, newLines), + "", + ].join("\n"); +} + +/** + * One file's section of a unified patch, built here because Azure has no route that returns one: + * its diff routes name the files that changed, 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 (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { + // Git's own wording for a file it will not spell out. + const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); + const newSide = quoteGitPatchPath(`b/${input.change.path}`); + const binary = `Binary files ${oldSide} and ${newSide} differ`; + return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; + } + if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; + } + + // A creation or deletion has nothing on one side, so writing both sides in full is already the + // minimal patch and needs no diff search. + const created = oldContents === "" && newContents !== ""; + const deleted = newContents === "" && oldContents !== ""; + if (created || deleted) { + const contents = created ? newContents : oldContents; + const lines = contentLines(contents); + // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. + // Checked against bytes-plus-marker-count rather than building the section, since that upper + // bound is cheaper and this is exactly the file shape that would cost the most to build. + if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + const section = replacementSection(header, input.texts); + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; + } + return { section, truncated: false, abandoned: false, edits: lines.length }; + } + + const patch = structuredPatch( + `a/${input.change.oldPath}`, + `b/${input.change.path}`, + oldContents, + newContents, + undefined, + undefined, + { + context: PATCH_CONTEXT_LINES, + maxEditLength: MAX_FILE_DIFF_EDITS, + timeout: MAX_FILE_DIFF_MILLIS, + }, + ); + // Hitting the edit ceiling lists the file without hunks rather than falling back to a full + // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having + // changed in one small corner, and both sides in full would bury that corner in a wall of text. + if (patch === undefined) { + return { + section: `${header}\n`, + truncated: true, + abandoned: true, + edits: MAX_FILE_DIFF_EDITS, + }; + } + + let edits = 0; + const hunks = patch.hunks.map((hunk) => { + for (const line of hunk.lines) { + if (line.startsWith("+") || line.startsWith("-")) edits += 1; + } + return [ + `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, + ...hunk.lines, + ].join("\n"); + }); + // A pure rename has no hunks to give but is still listed, since dropping it would take the file + // out of the change altogether. + const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; + // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus + // context can still exceed the size ceiling even well inside the edit ceiling. + if (byteLength(section) > MAX_FILE_BYTES) { + return { section: `${header}\n`, truncated: true, abandoned: false, edits }; + } + return { section, truncated: false, abandoned: false, edits }; +} + +/** A file listed without its hunks, for when the host would not hand one of its two sides over. */ +export function azureDevOpsUnreadableFilePatch( + change: AzureDevOpsChangeEntry, +): AzureDevOpsFilePatch { + return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; +} diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 6f55eb937b..8e863b8751 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, @@ -187,17 +190,15 @@ describe("decodePullRequestJson", () => { expect(unspecified?.autoMergeMethod).toBeUndefined(); }); - 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( @@ -212,7 +213,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", () => { @@ -341,3 +342,311 @@ 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 page = 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(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ + ["DEMO.md", "new"], + ["README.md", "change"], + ["OLD.md", "deleted"], + ]); + }); + + it("keeps a space at the end of a file's name, which belongs to the name", () => { + // Git will carry a name that ends in a space, and the patch and the viewed mark are both + // keyed by it. Tidying it here files the change under a name nothing else uses. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "edit", item: { path: "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/docs/readme.md ", objectId: "ec00" } }, + { + changeType: "rename", + sourceServerItem: "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/docs/old.md ", + item: { path: "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/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ + ["docs/readme.md ", "docs/readme.md "], + ["docs/moved.md", "docs/old.md "], + ]); + }); + + it("reads a rename as one file that moved, and says whether it also changed", () => { + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { + changeType: "rename", + sourceServerItem: "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/docs/old.md", + item: { path: "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/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + { + changeType: "edit, rename", + sourceServerItem: "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/src/old.ts", + item: { path: "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/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, + }, + ], + }), + ), + ); + + expect(page.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 page = expectSuccess( + decodeIterationChangesJson( + asJson({ + changeEntries: [ + { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, + { changeType: "add", item: { path: "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/docs/page.md", objectId: "dddd" } }, + ], + }), + ), + ); + + 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: "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/docs/old.md", + item: { path: "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/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, + }, + ], + }), + ), + ); + + expect(page.changes.at(0)?.oldPath).toBe("docs/old.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" }))), + ).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" })))).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 }); + }); +}); + +/** + * Every other fixture in this file is written from Azure's published contract. These are the + * shapes a real organisation answered with, read back through `az devops invoke` rather than + * `az rest`: the extension hands over the route's own body with one key of its own added, and a + * live repository leaves out fields the contract documents. + */ +describe("what az devops invoke answers with", () => { + it("reads the envelope the extension adds its own continuation token to", () => { + // Set on every JSON body it returns, from a response header these routes do not send, so it + // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. + expect( + expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), + ).toEqual([]); + + expect( + expectSuccess( + decodeIterationsJson( + asJson({ + count: 1, + continuation_token: null, + value: [ + { + id: 1, + sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, + commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, + }, + ], + }), + ), + ), + ).toEqual([ + { + id: 1, + headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", + mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", + }, + ]); + }); + + it("keeps the files of a change that names neither their object type nor their page after it", () => { + // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no + // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences + // is what the defaults in the decoder are for, and reading any of them as stated would drop + // every file of every Azure change request. + const page = expectSuccess( + decodeIterationChangesJson( + asJson({ + continuation_token: null, + changeEntries: [ + { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, + { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, + ], + }), + ), + ); + + expect(page.nextSkip).toBeNull(); + expect(page.changes).toEqual([ + { + path: "DEMO.md", + oldPath: "DEMO.md", + changeKind: "new", + objectId: "EC005DB24", + originalObjectId: null, + }, + { + path: "README.md", + oldPath: "README.md", + changeKind: "change", + objectId: "8F8047A49", + originalObjectId: null, + }, + ]); + }); + + it("reads a text file whose content type Azure calls a stream", () => { + // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and + // no `isBinary` at all, so the content type is not the field to ask, and its absence is the + // answer that the text is text. + expect( + expectSuccess( + decodeItemContentJson( + asJson({ + path: "/README.md", + objectId: "8f8047a49", + gitObjectType: "blob", + content: "# T3Demo\n", + contentMetadata: { + contentType: "application/octet-stream", + encoding: 65001, + extension: "md", + fileName: "README.md", + }, + continuation_token: null, + }), + ), + ), + ).toEqual({ contents: "# T3Demo\n", isBinary: false }); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 55d9b544ab..2e949e1039 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -12,10 +12,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 @@ -117,6 +114,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; @@ -137,8 +144,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; /** The completion strategy Azure stored with auto-complete, where it reported one. */ @@ -188,15 +195,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 }; } function toAutoMergeMethod( @@ -259,7 +267,7 @@ function toPullRequest( body: raw.description ?? "", reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, - threadsUrl: toThreadsUrl(raw), + location: toLocation(raw), autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), }; @@ -370,3 +378,197 @@ 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)), + /** 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({ + 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), + /** 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. */ +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; +} + +/** + * 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); +const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); +const decodeItemContent = decodeJsonResult(RawItemContentSchema); + +/** + * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like + * the rest of this payload, since a leading/trailing space is a legal part of a file's name and + * the patch and viewed mark are keyed by the untrimmed path. + */ +function toRepositoryPath(value: string | null | undefined): string | null { + if (value === undefined || value === null) return null; + const path = value.replace(/^\/+/, ""); + return path.length === 0 ? null : path; +} + +/** + * 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 { + 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; + // 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, + changeKind: toChangeKind(change.changeType, oldPath !== path), + objectId: trimmed(change.item?.objectId), + originalObjectId: trimmed(change.item?.originalObjectId), + }); + } + 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. + * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. + */ +export function decodeItemContentJson( + raw: string, +): Result.Result { + const decoded = decodeItemContent(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + contents: decoded.success.content ?? "", + isBinary: decoded.success.contentMetadata?.isBinary === true, + }) + : Result.fail(decoded.failure); +} diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts new file mode 100644 index 0000000000..1426a67d29 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts @@ -0,0 +1,280 @@ +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); + }); + + it("reads a name git had to quote, here one holding a tab", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"', + "index 4444444..5555555 100644", + '--- "a/we\\tird.ts"', + '+++ "b/we\\tird.ts"', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]); + }); + + it("rejoins the octal bytes git writes for a name outside ASCII", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"', + "index 6666666..7777777 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]); + }); + + it("splits a rename header where git quoted only the side that needed it", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git a/old.ts "b/new\\tname.ts"', + "similarity index 90%", + "rename from old.ts", + 'rename to "new\\tname.ts"', + "index 8888888..9999999 100644", + "--- a/old.ts", + '+++ "b/new\\tname.ts"', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]); + }); + + it("names a quoted rename that changed nothing, which states no paths of its own", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', + "similarity index 100%", + 'rename from "old\\tname.ts"', + 'rename to "new\\tname.ts"', + "index abcabca..abcabca 100644", + ), + ); + + assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]); + }); + + it("keeps a character from outside the basic plane that git left unescaped", () => { + // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header + // for the tab. Encoding what it left one unit at a time would split the pair into two halves. + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"', + "index aaaaaaa..bbbbbbb 100644", + "@@ -1 +1 @@", + ), + ); + + assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]); + }); + + it("drops the tab git ends a name holding a space with", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/with space.txt b/with space.txt", + "index 1111111..6178079 100644", + "--- a/with space.txt\t", + "+++ b/with space.txt\t", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]); + }); + + it("drops that tab from a quoted name too, where it lands past the closing quote", () => { + const revisions = parseDiffFileRevisions( + patchOf( + 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"', + "index 2222222..7777777 100644", + '--- "a/we ird\\tname.ts"\t', + '+++ "b/we ird\\tname.ts"\t', + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]); + }); + + it("reads a deletion whose name holds a space, tab and all", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/with space.txt b/with space.txt", + "deleted file mode 100644", + "index 3333333..0000000", + "--- a/with space.txt\t", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-a", + ), + ); + + assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]); + }); + + it("drops the timestamp other producers of the format write past that tab", () => { + const revisions = parseDiffFileRevisions( + patchOf( + "diff --git a/stamped.ts b/stamped.ts", + "index 4444444..5555555 100644", + "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000", + "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000", + "@@ -1 +1 @@", + "-a", + "+b", + ), + ); + + assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]); + }); + + it("splits an unquoted header whose names hold a space, by the sides agreeing", () => { + const revisions = parseDiffFileRevisions( + patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"), + ); + + assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts new file mode 100644 index 0000000000..135a23dfdf --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts @@ -0,0 +1,129 @@ +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; + +const ENTRY = "diff --git "; +const QUOTE = '"'; + +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; +} + +/** Where a quoted name closes, given git escapes every quote the name itself holds. */ +function quotedEnd(rest: string): number { + for (let at = 1; at < rest.length; at += 1) { + const char = rest.charAt(at); + if (char === "\\") { + at += 1; + continue; + } + if (char === QUOTE) return at; + } + return -1; +} + +/** + * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the + * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with + * that tab escaped, so the first literal tab is never part of what the file is called. + */ +function sidePath(rest: string, prefix: string): string | null { + const tab = rest.indexOf("\t"); + const token = tab === -1 ? rest : rest.slice(0, tab); + if (token === "/dev/null") return null; + const path = unquoteGitPatchPath(token); + return path.startsWith(prefix) ? path.slice(prefix.length) : path; +} + +function headerSide(token: string, prefix: string): string | null { + const path = unquoteGitPatchPath(token); + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + +/** + * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one + * two` can split in more than one place, so the split leaving both sides equal wins; a rename + * (the only case where sides differ) states its names on separate lines instead. A quoted name + * ends at its own closing quote and needs none of that guessing. + */ +function headerPaths(rest: string): readonly [string | null, string | null] { + if (rest.startsWith(QUOTE)) { + const end = quotedEnd(rest); + if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null]; + return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")]; + } + if (rest.endsWith(QUOTE)) { + const opens = rest.indexOf(QUOTE); + if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null]; + return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")]; + } + 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, as the blob ids from a unified patch's `index ..` + * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except + * for a deletion where only the old name exists. An entry with no `index` line (most often one + * Bitbucket excluded by pattern) is left out. + */ +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 = unquoteGitPatchPath(line.slice("rename from ".length)); + } else if (line.startsWith("rename to ")) { + entry.newPath = unquoteGitPatchPath(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; +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 2c3ac6f7c0..7b8857179a 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -6,10 +6,12 @@ import { buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, buildReviewerRequestJson, + buildSetFilesViewedGraphQlMutation, decodeBaseComparisonJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestFilesViewedJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -1402,6 +1404,57 @@ describe("review submission payload", () => { }); describe("decodePullRequestFilesJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: String.raw`src\notes.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + previous_filename: String.raw` old\name.ts `, + filename: String.raw` new\name.ts `, + status: "renamed", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + "", + ].join("\n"), + ); + }); + it("assembles a unified patch the files API does not return", () => { const result = expectSuccess( decodePullRequestFilesJson( @@ -1570,6 +1623,100 @@ describe("how far a branch trails its base", () => { }); }); +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', + ); + }); +}); + describe("host-native stack decoding", () => { /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ function stack(overrides: Record = {}) { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 1ebe34ae27..1180d8040d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -10,6 +10,7 @@ import type { PullRequestChecksState, PullRequestComment, PullRequestCommit, + PullRequestFileViewedState, PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeMethod, @@ -30,6 +31,7 @@ import type { PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; @@ -1402,16 +1404,18 @@ function toComments(raw: { readonly comments?: ReadonlyArray> | undefined; readonly reviews?: ReadonlyArray> | undefined; }): ReadonlyArray { - const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ - id: comment.id, - kind: "issue-comment", - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - url: trimmed(comment.url), - path: null, - reviewState: null, - })); + const issueComments = (raw.comments ?? []).map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + }), + ); // A review with no body is kept only when its state is the event itself — an approval, a // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the // container for line comments, and those comments are read from the review threads, so @@ -1860,17 +1864,19 @@ export function reviewThreadConversation( threads: ReadonlyArray, ): ReadonlyArray { return threads.flatMap((thread) => - thread.comments.map((comment): PullRequestComment => ({ - id: comment.id, - kind: "review-comment", - author: comment.author, - body: comment.body, - createdAt: comment.createdAt, - url: comment.url, - path: thread.path, - reviewState: null, - reactions: comment.reactions ?? [], - })), + thread.comments.map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + }), + ), ); } @@ -2548,16 +2554,21 @@ export function decodePullRequestFilesJson( } // A rename counts its hunks against the old path, which is the only place it is named. const oldPath = - status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename; + status === "renamed" ? value.previous_filename || value.filename : value.filename; const header = [ - `diff --git a/${oldPath} b/${value.filename}`, + `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, // The files API reports no file mode, so the ordinary one stands in: the viewer reads // these lines as "added" and "removed" rather than for the mode they carry. ...(status === "added" ? ["new file mode 100644"] : []), ...(status === "removed" ? ["deleted file mode 100644"] : []), - ...(status === "renamed" ? [`rename from ${oldPath}`, `rename to ${value.filename}`] : []), - `--- ${status === "added" ? "/dev/null" : `a/${oldPath}`}`, - `+++ ${status === "removed" ? "/dev/null" : `b/${value.filename}`}`, + ...(status === "renamed" + ? [ + `rename from ${quoteGitPatchPath(oldPath)}`, + `rename to ${quoteGitPatchPath(value.filename)}`, + ] + : []), + `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`, + `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`, ].join("\n"); sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); } @@ -2569,6 +2580,119 @@ export function decodePullRequestFilesJson( }); } +/** + * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST + * files endpoint the patch is read from carries no viewed state, so this is a second read rather + * than a wider version of the first. + */ +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 `markFileAsViewed`/`unmarkFileAsViewed`, which each + * take a single path, so the batching is done with aliases; top-level mutation fields run in + * write order, so the last word about a path is the one that sticks. + * + * Paths travel as variables rather than interpolated into the document, since a path is data + * and a document is not. + */ +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])), + }; +} + /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ const RawStackPullRequestSchema = Schema.Struct({ title: Schema.optional(Schema.String), diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 4438aa0c52..c9bc127437 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, @@ -381,6 +382,57 @@ describe("decodeCommitsJson", () => { }); describe("decodeMergeRequestDiffsJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw`src\notes.ts`, + new_path: String.raw`src\notes.ts`, + diff: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw` old\name.ts `, + new_path: String.raw` new\name.ts `, + renamed_file: true, + diff: "", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + ].join("\n"), + ); + }); + it("assembles a unified patch GitLab does not return", () => { const result = expectSuccess( decodeMergeRequestDiffsJson( @@ -630,3 +682,122 @@ describe("gitLabAwardName", () => { expect(gitLabAwardName("hooray")).toBe("tada"); }); }); + +describe("decodeRepositoryBlobsJson", () => { + /** Null is the query going unanswered, which these cases are not about. */ + function expectBlobs(result: Result.Result | null, unknown>) { + const blobs = expectSuccess(result); + expect(blobs).not.toBe(null); + if (blobs === null) throw new Error("expected an answered blobs query"); + return blobs; + } + + it("reads a blob id per path", () => { + const blobs = expectBlobs( + 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 = expectBlobs( + 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("keys a blob by the path the host spelled, spaces and all", () => { + // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands + // under a key the asked-for path is not spelled with, and the caller fills that path in as + // the empty revision: a mark on the file then never compares against the real head blob. + const blobs = expectBlobs( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: " leading.ts", oid: "aaa111" }, + { path: "trailing.ts ", oid: "bbb222" }, + { path: " ", oid: "ccc333" }, + { path: "", oid: "ddd444" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + [" leading.ts", "aaa111"], + ["trailing.ts ", "bbb222"], + // A name that is only spaces is one Git carries too, so it is a path like any other. + [" ", "ccc333"], + ]); + }); + + it("tells a project the reader cannot see from a revision with none of the files", () => { + // Null is the query going unanswered. Read as an empty answer it would say the head has none + // of the asked-for files, which reports every file a reader has cleared as changed. + expect( + expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), + ), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), + ), + ), + ).toEqual(new Map()); + }); + + 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 4b3c797136..b9991920d0 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -19,6 +19,7 @@ import type { PullRequestState, } from "@t3tools/contracts"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** @@ -680,8 +681,8 @@ function diffHeaderPaths(raw: Schema.Schema.Type): { readonly to: string; } { return { - from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, - to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, + from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), + to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), }; } @@ -718,11 +719,14 @@ export function decodeMergeRequestDiffsJson( } const { from, to } = diffHeaderPaths(value); const header = [ - `diff --git a/${value.old_path} b/${value.new_path}`, + `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), ...(value.renamed_file === true - ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] + ? [ + `rename from ${quoteGitPatchPath(value.old_path)}`, + `rename to ${quoteGitPatchPath(value.new_path)}`, + ] : []), `--- ${from}`, `+++ ${to}`, @@ -947,3 +951,82 @@ 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, since 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 deleted file. + */ +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, or null where GitLab did not answer the query at all (a project the token + * cannot see, or a repository with no blobs connection). That case must be told apart from an + * empty answer: read as "the revision has none of these files", it would report every cleared + * file as changed again. A node missing either half is left out, since the caller treats an + * absent path as one the revision does not carry. + */ +export function decodeRepositoryBlobsJson( + raw: string, +): Result.Result | null, DecodeFailure> { + const decoded = decodeRepositoryBlobs(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const nodes = decoded.success.data.project?.repository?.blobs?.nodes; + if (nodes === undefined || nodes === null) return Result.succeed(null); + const blobs = new Map(); + for (const node of nodes) { + // Not trimmed, unlike everything else read out of this payload: a leading or trailing space + // is a legal part of a file's name, and trimming it would key this map under a name the + // caller's asked-for path never matches. + const path = node?.path; + const oid = trimmed(node?.oid); + if (path === undefined || path === null || path.length === 0 || oid === null) continue; + blobs.set(path, oid); + } + return Result.succeed(blobs); +} diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts new file mode 100644 index 0000000000..1f9aa33ac7 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -0,0 +1,423 @@ +/** + * The marks a reader has ticked off, for a host that keeps none of its own, and the held record of + * what the head has of those files. The revisions cache is filed here because this is its only + * consumer: when a second one appears, export `makeFileRevisions` and split it into its own file. + */ +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; +import { + PullRequestOperationError, + type PullRequestFilesViewedResult, + type PullRequestRef, + type PullRequestSetFilesViewedInput, +} from "@t3tools/contracts"; + +import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; +import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; +import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; + +/** + * How long the head's version of a file is believed, and how long a held answer stands while the + * next one is fetched. 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); +export const FILE_REVISIONS_CACHE_CAPACITY = 64; + +/** + * How many paths one scope's entry carries. Bounds a single scope, not how many scopes are held: + * a reader ticking one file after another renews the same scope and grows it without limit + * otherwise. + */ +export const MAX_FILE_REVISION_PATHS = 1_000; + +interface FileRevisionsDependencies { + readonly runFork: (effect: Effect.Effect) => unknown; + readonly refEpoch: (ref: PullRequestRef) => number; + readonly fileRevisionsEpoch: () => number; + readonly toPullRequestError: ( + operation: string, + ) => (error: PullRequestProviderError) => PullRequestError; +} + +const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { + const { runFork, refEpoch, fileRevisionsEpoch, toPullRequestError } = dependencies; + /** + * What the head has of the files a reader has marked, held between reads. The entry tracks what + * has been asked as well as what was heard, since a path missing from an answer keeps whatever + * version was last given for it rather than being cleared. + */ + interface HeldFileRevisions { + readonly at: number; + readonly asked: ReadonlySet; + readonly revisions: ReadonlyMap; + } + const heldFileRevisions = new Map(); + const refreshingFileRevisions = new Set(); + + /** + * Carries the reference's epoch, so whatever moved the head strands what was held (or in + * flight) against the old one. Spelled from the project rather than the reference, since a + * reference arrives however the client spelled it while the epoch is bumped against the + * remote's own spelling. + */ + const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) => + [ + refEpoch({ ...ref, host: project.host, repository: project.repository }), + fileRevisionsEpoch(), + ref.projectId, + project.repository.trim().toLowerCase(), + ref.number, + ].join(" "); + + /** + * `paths` are what was asked about, and are held as answered for whether the host had a version + * for them or not: that is what stops the same question being asked again. A `complete` answer + * adds every other path it carries, since a host that read the whole change to answer for one + * file has already paid for all of them, and the tick after this one names a file nothing has + * asked about yet. + */ + const recordFileRevisions = ( + key: string, + paths: ReadonlyArray, + answer: ProviderFileRevisions, + ) => + Effect.map(Clock.currentTimeMillis, (at) => { + const held = heldFileRevisions.get(key); + // 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 ?? []); + // The paths asked for go last, so a whole-change answer wider than the cap is trimmed down + // to the reader's own files rather than over them. + const learned = answer.complete === true ? [...answer.revisions.keys(), ...paths] : paths; + for (const path of learned) { + // Reinserted rather than added, so what a full entry drops below is the path nobody has + // asked about in the longest rather than one just asked for. + asked.delete(path); + asked.add(path); + const revision = answer.revisions.get(path); + // A path left out of the answer keeps its last known version rather than being cleared. + if (revision !== undefined) { + revisions.delete(path); + revisions.set(path, revision); + } + } + for (const path of asked) { + if (asked.size <= MAX_FILE_REVISION_PATHS) break; + asked.delete(path); + revisions.delete(path); + } + heldFileRevisions.delete(key); + if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) { + const oldest = heldFileRevisions.keys().next().value; + if (oldest !== undefined) heldFileRevisions.delete(oldest); + } + // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial + // answer would let an old revision ride past the point it should have been re-read. + const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path)) + ? at + : (carried?.at ?? at); + heldFileRevisions.set(key, { at: stamped, asked, revisions }); + return revisions; + }); + + /** A held entry that covers every path asked for and is still worth answering from. */ + const heldFileRevisionsFor = (key: string, paths: ReadonlyArray, now: number) => { + const held = heldFileRevisions.get(key); + if (held === undefined) return null; + // Put back at the end on every read, so the scope a reader is working through is not the one + // evicted by a listing walking scopes nobody has open. + heldFileRevisions.delete(key); + heldFileRevisions.set(key, held); + if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null; + return paths.every((path) => held.asked.has(path)) ? held : null; + }; + + /** + * What the head has of these files, or null where the host cannot say (not an error: the marks + * just stop reporting staleness). `held` answers from a stale value and refetches off the + * critical path, since a badge a moment behind beats a page that won't paint until the host + * answers; `fresh` is for the press itself, which must not store a revision the head already + * moved off. + */ + const fileRevisionsOf = ( + project: SupportedProject, + ref: PullRequestRef, + paths: ReadonlyArray, + operation: string, + freshness: "held" | "fresh" = "held", + ): Effect.Effect | null, PullRequestError> => { + const read = project.api.getFileRevisions; + if (read === undefined) return Effect.succeed(null); + // 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(() => { + const key = fileRevisionsKey(project, ref); + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: ref.number, + paths, + }).pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((answer) => recordFileRevisions(key, paths, answer)), + ); + }); + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const key = fileRevisionsKey(project, ref); + const held = heldFileRevisionsFor(key, 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(key)) 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(key); + runFork( + Effect.ignore(fetch).pipe( + Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(key))), + ), + ); + }).pipe(Effect.as(held.revisions)); + }); + }; + + return { fileRevisionsOf } as const; +}; + +export interface Dependencies extends FileRevisionsDependencies { + readonly filesViewedStore: PullRequestFilesViewed.PullRequestFilesViewedRepository["Service"]; + readonly requireProject: ( + ref: PullRequestRef, + ) => Effect.Effect; + readonly requiredViewerOf: ( + project: SupportedProject, + operation: string, + ) => Effect.Effect; +} + +// A plain factory rather than a `Context.Service` (against the preference in +// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct +// at one instance per service, and a layer provided at two points would give two of each behind +// one epoch counter. +export const make = (dependencies: Dependencies) => { + const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; + const { fileRevisionsOf } = makeFileRevisions(dependencies); + /** + * Which change request's marks, and whose. Provider and host lead the key because the same + * repository can exist on more than one install; the reader is part of it because a host's own + * record is per-account. A host that names no reader is one reader, not none. + */ + const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ + provider: project.api.kind, + host: project.host, + repository: project.remote, + 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, + }); + + /** + * 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. Revisions are asked for the marked paths alone, so a reader who has + * marked nothing costs no host call. + */ + const environmentFilesViewed = ( + project: SupportedProject, + ref: PullRequestRef, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* requiredViewerOf(project, "filesViewed"); + const held = yield* filesViewedStore + .list(filesViewedScope(project, ref.number, viewer)) + .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); + const marks = held.files; + if (marks.length === 0) return { files: [], truncated: held.truncated }; + // A host that won't say what its head has costs the marks their staleness (`fileRevisionsOf` + // returns null), rather than costing the reader every tick they've made. + const revisions = yield* fileRevisionsOf( + project, + ref, + marks.map((mark) => mark.path), + "filesViewed", + ).pipe( + Effect.catch((error) => + Effect.logWarning("reporting viewed files without what the head has of them", { + operation: "filesViewed", + reason: error._tag, + }).pipe(Effect.as(null)), + ), + ); + return { + files: marks.map((mark) => { + // A mark stamped with no baseline holds until the reader presses it again. + if (mark.revision === null) return { path: mark.path, state: "viewed" as const }; + const revision = revisions?.get(mark.path); + // A deleted file is answered as the empty revision, matching its stamp, so it stays + // cleared; a path the host had no answer for (`undefined`) also holds as cleared. + return { + path: mark.path, + state: + revision === undefined || revision === mark.revision + ? ("viewed" as const) + : ("dismissed" as const), + }; + }), + // The store caps marks per scope; a reader over that cap is told so, like a paginated read. + truncated: held.truncated, + }; + }); + + /** + * One environment-backed write at a time per change request. A tick's host round trip is + * slower than an untick's, so unordered presses could finish out of order and leave a stale + * tick standing over a later untick. + */ + const filesViewedGates = new Map< + string, + { readonly gate: Semaphore.Semaphore; pending: number } + >(); + + const inFilesViewedOrder = ( + project: SupportedProject, + number: number, + write: Effect.Effect, + ) => + // Suspended rather than generated, so finding the gate, putting it in and taking a place in + // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert + // lets two presses each make a gate of their own and neither wait on the other. + Effect.suspend(() => { + const key = `${project.project.id} ${project.remote} ${number}`; + const held = filesViewedGates.get(key); + const entry = held ?? { gate: Semaphore.makeUnsafe(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 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, + ): Effect.Effect => + Effect.gen(function* () { + const viewer = yield* requiredViewerOf(project, "setFilesViewed"); + // 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, cleared, "setFilesViewed", "fresh").pipe( + // A host that won't say what its head has costs the press its baseline, not the + // press itself: the mark is stored with none and holds until pressed again. + Effect.catch((error) => + Effect.logWarning("recording viewed files without what the head has of them", { + operation: "setFilesViewed", + reason: error._tag, + }).pipe(Effect.as(null)), + ), + ); + const viewedAt = DateTime.formatIso(yield* DateTime.now); + yield* filesViewedStore + .set({ + ...filesViewedScope(project, input.number, viewer), + // A path left out of the answer stores with no baseline, not the empty revision, since + // the empty revision is itself an answer and would misreport the file once it turns + // out to have a version after all. + files: input.files.map((file) => ({ + path: file.path, + revision: revisions?.get(file.path) ?? null, + viewed: file.viewed, + })), + viewedAt, + }) + .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); + }); + + const filesViewed = (input: PullRequestRef) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const read = project.api.getFilesViewed; + 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); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "filesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + ); + + const setFilesViewed = ( + input: PullRequestSetFilesViewedInput, + ): Effect.Effect => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const write = project.api.setFilesViewed; + 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 inFilesViewedOrder( + project, + input.number, + environmentSetFilesViewed(project, input), + ); + } + return Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "This host does not track which files a reader has seen.", + }), + ); + }), + ); + + return { filesViewed, setFilesViewed }; +}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e3eb8af1cb..fa68b1c96d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -740,7 +740,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 4d2c7bd203..621a6187cd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,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"; @@ -403,6 +404,8 @@ const RepositoryIdentityResolverLayerLive = Layer.effect( const PullRequestServiceLive = PullRequestService.layer.pipe( 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(PullRequestReadCache.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index d11d1a8707..6179a61aa6 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -77,11 +77,11 @@ function encodeAzureDevOpsPathSegment(segment: string): string { } /** - * The organization root a REST url belongs to, which is where a browser url and any further - * REST call have to be hung. Exported because the pull requests page derives its own urls from - * whatever Azure returned rather than from the local remote, whose shape varies. + * The organization root a REST url belongs to, which is where a browser url has to be hung when + * Azure answered with neither a web link nor a repository url. Read from what Azure returned + * rather than from the local remote, whose shape varies. */ -export function azureDevOpsOrganizationBaseFromRestApiUrl( +function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { const rawUrl = trimOptionalString(value); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f9b5e2d224..c77c7ffdba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3080,6 +3080,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, @@ -3139,7 +3149,7 @@ const makeWsRpcLayer = ( // A reader asking for fresh host state also wants the thread badges it feeds to // catch up, including a merged link the sweep would otherwise never revisit. Effect.andThen( - input.reference === undefined + input.reference === undefined || input.filesViewedOnly === true ? Effect.void : resolvePullRequestSyncKey(input.reference).pipe( Effect.flatMap((key) => diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 510c47b155..09973476cf 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -17,6 +17,7 @@ import { ChevronsUpDownIcon, Columns2Icon, FolderTreeIcon, + InfoIcon, MessageSquareIcon, MessageSquareOffIcon, PilcrowIcon, @@ -45,6 +46,7 @@ import { resolveFileDiffPreviousPath, type RenderablePatch, } from "~/lib/diffRendering"; +import { APP_BASE_NAME } from "~/branding"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; @@ -64,6 +66,7 @@ import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, @@ -79,9 +82,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, @@ -224,6 +229,8 @@ function PullRequestCodeTab({ const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState(null); + const effectiveFoldOverride = + foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"); const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); @@ -333,13 +340,6 @@ 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); @@ -413,6 +413,35 @@ 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 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: viewedFilesStore !== undefined, + paths: filePaths, + }); + const { + setViewed, + refresh: refreshFilesViewed, + enabled: filesViewedEnabled, + isViewed: isFileViewed, + isStale: isFileViewedStale, + } = 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. @@ -445,7 +474,9 @@ function PullRequestCodeTab({ return placed; }, [commit, detail.reviewThreads, files]); - const items = useMemo[]>( + // Hashing what the annotations show is the costly part of an item's version, and none of it + // moves when a file is ticked or folded, so it is kept apart from the two that do. + const annotatedFiles = useMemo( () => files.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); @@ -487,27 +518,20 @@ function PullRequestCodeTab({ groupAt(anchor.side, anchor.line).draft = true; } - const collapsed = isFileDiffCollapsed( - fileKey, - foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"), - toggledFiles, - ); - const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), lineNumber: group.line, metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, })); return { - id: fileKey, - type: "diff" as const, + fileKey, + path, fileDiff, annotations, - collapsed, // 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 + annotationsVersion: fnv1a32( + annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending @@ -532,19 +556,37 @@ function PullRequestCodeTab({ ) .join(",")}`, ) - .join("|")}`, + .join("|"), ), }; }), + [commit, detail.reviewThreads, draft, files, pendingComments, placedThreadIds], + ); + + const items = useMemo[]>( + () => + annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => { + const collapsed = isFileDiffCollapsed(fileKey, effectiveFoldOverride, toggledFiles); + // 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 = filesViewedEnabled + ? `e${isFileViewed(path) ? "v" : ""}${isFileViewedStale(path) ? "s" : ""}` + : ""; + return { + id: fileKey, + type: "diff" as const, + fileDiff, + annotations, + collapsed, + version: fnv1a32(`${collapsed ? "1" : "0"}:${viewedMark}:${annotationsVersion}`), + }; + }), [ - commit, - detail.reviewThreads, - draft, - files, - foldOverride, - pendingComments, - placedThreadIds, - settings.diffFilesCollapsed, + annotatedFiles, + filesViewedEnabled, + effectiveFoldOverride, + isFileViewed, + isFileViewedStale, toggledFiles, ], ); @@ -609,6 +651,19 @@ 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, effectiveFoldOverride, current), + ); + }, + [effectiveFoldOverride, setViewed], + ); + const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey); const revealFile = useCallback( (path: string) => { @@ -752,6 +807,14 @@ 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. + 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; @@ -761,17 +824,52 @@ 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 = ( ); + const viewedFiles = filesViewedRef.current; + if (!viewedFiles.enabled) return stat; + const viewed = viewedFiles.isViewed(path); + const stale = viewedFiles.isStale(path); + return ( + + {stat} + {/* The header itself folds the file, so the tick keeps its press to itself. The + attribute is what the header's capture listener looks for. */} + + + ); }, [omittedFileStats], ); @@ -1096,6 +1194,62 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {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. The count holds its + width and the wording gives way, so the narrow right panel keeps its controls. */} + + {filesViewed.viewedCount} / {files.length} + + + {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "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.error !== null ? ( + + }> + + + + The boxes below are whatever was last read, and empty if nothing has been read + yet. {filesViewed.error} + + + ) : null} + {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 ? ( }> @@ -1404,6 +1558,8 @@ function PullRequestCodeTab({ if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { return; } + // The viewed-file toggle is a label inside the header; its own control handles it. + if (node.hasAttribute("data-viewed-toggle")) return; if (node.hasAttribute("data-diffs-header")) { const filePath = node.querySelector("[data-title]")?.textContent?.trim(); if (filePath === undefined || filePath === "") return; diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index 203be64eeb..181ebe6cfe 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,30 @@ 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 expanded, so ticking one off is the case that has somewhere to go. + expect([...toggleFileDiffFoldForViewed("a.ts", true, null, new Set())]).toEqual(["a.ts"]); + }); + + it("brings a file back when the tick is taken off", () => { + expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set(["a.ts"]))]).toEqual([]); + }); + + it("leaves the fold alone when it already says what the tick does", () => { + const folded = new Set(["a.ts"]); + 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", false, 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 e818e989d2..b7b836cb3b 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -41,3 +41,24 @@ export function isFileDiffCollapsed( const foldedByDefault = foldOverride === "folded"; 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 0000000000..72d5a631ac --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + countViewedFiles, + isFileViewed, + isStaleViewedState, + revertFileViewedOverlay, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, +} from "./pullRequestFilesViewed.logic"; + +const NO_OVERLAY: FileViewedOverlay = new Map(); +const NOTHING_PENDING: ReadonlySet = new Set(); +const NOTHING_ANSWERED: 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, + NOTHING_ANSWERED, + ); + 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, NOTHING_ANSWERED)).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"]), NOTHING_ANSWERED); + 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, + NOTHING_ANSWERED, + ); + expect(settled.size).toBe(0); + }); + + it("drops a tick once a read has answered for it, against what the reader pressed", () => { + // The tick landed, the file was pushed to before the read that followed it came back, and the + // host answers `dismissed`. Holding the tick would hide that push for as long as the tab + // stayed open, and no refresh would recover it: every later answer says `dismissed` too. + const settled = settleFileViewedOverlay( + new Map([["c.ts", true]]), + states, + NOTHING_PENDING, + new Set(["c.ts"]), + ); + expect(settled.size).toBe(0); + expect(isFileViewed("c.ts", states, settled)).toBe(false); + expect(isStaleViewedState(states?.get("c.ts"))).toBe(true); + }); + + it("holds a press made since the read that would otherwise answer for it", () => { + const overlay = new Map([["c.ts", true]]); + const settled = settleFileViewedOverlay(overlay, states, new Set(["c.ts"]), new Set(["c.ts"])); + expect(settled.get("c.ts")).toBe(true); + }); + + it("holds everything until the host has answered at all", () => { + const overlay = new Map([["a.ts", true]]); + expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING, NOTHING_ANSWERED)).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 }, + ]); + }); +}); + +describe("revertFileViewedOverlay", () => { + const batch = [ + { 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 answers for", () => { + const overlay = new Map([ + ["a.ts", true], + ["b.ts", false], + ]); + expect(revertFileViewedOverlay(overlay, batch, both).size).toBe(0); + }); + + it("leaves a press the reader made after the request went out", () => { + // 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(["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, both)).toBe(overlay); + }); +}); 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 0000000000..409013a1b2 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -0,0 +1,100 @@ +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. + */ +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 until a read that + * could have seen it comes back, rather than cleared when the write succeeds: that read is a + * separate round trip, and dropping the press in between flashes the checkbox back. + * + * `pending` are the paths whose press the host cannot have heard yet, which an answer already on + * its way must not overrule. `answered` are the paths a read has landed for since their write was + * acknowledged, and those go on that read alone, including where it contradicts the press: a tick + * held over a `dismissed` would hide a file pushed to since, with no refresh able to recover it. + */ +export function settleFileViewedOverlay( + overlay: FileViewedOverlay, + states: FileViewedStates | null, + pending: ReadonlySet, + answered: ReadonlySet, +): FileViewedOverlay { + if (states === null || overlay.size === 0) return overlay; + const next = new Map(overlay); + for (const [path, pressed] of overlay) { + if (pending.has(path)) continue; + if (answered.has(path) || isViewedState(states.get(path)) === pressed) next.delete(path); + } + return next.size === overlay.size ? overlay : next; +} + +/** + * The overlay with a failed request's presses taken back. `owned` are the paths that request still + * answers for, which 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. + */ +export function revertFileViewedOverlay( + overlay: FileViewedOverlay, + batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, + owned: ReadonlySet, +): FileViewedOverlay { + const next = new Map(overlay); + for (const { path, viewed } of batch) { + if (!owned.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, +): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> { + return [...overlay].map(([path, viewed]) => ({ path, viewed })); +} diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx new file mode 100644 index 0000000000..78314b7ca7 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx @@ -0,0 +1,161 @@ +import { + EnvironmentId, + ProjectId, + type PullRequestFilesViewedResult, + type PullRequestRef, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, StrictMode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ + host: { data: null as unknown, refresh: vi.fn() }, + setFilesViewed: vi.fn(), + toastAdd: vi.fn(), +})); + +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ + data: host.data, + error: null, + isPending: false, + isSuccess: true, + refresh: host.refresh, + }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed })); +vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } })); + +import { + usePullRequestFilesViewed, + type PullRequestFilesViewedView, +} from "./usePullRequestFilesViewed"; + +const environmentId = EnvironmentId.make("pr-files-viewed-audit"); +const reference: PullRequestRef = { + projectId: ProjectId.make("project-a"), + repository: "acme/web", + number: 42, +}; +const paths = ["a.ts"]; + +/** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */ +function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { + return { files: [{ path: "a.ts", state }], truncated: false }; +} + +let renderer: ReactTestRenderer | null = null; + +function Probe(_props: { readonly view: PullRequestFilesViewedView }) { + return null; +} + +function Surface() { + const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths }); + return ; +} + +function view(): PullRequestFilesViewedView { + return renderer!.root.findByType(Probe).props.view; +} + +/** The host's next answer landing, which is what a `refresh` ends in. */ +async function reads(state: "unviewed" | "viewed" | "dismissed") { + host.data = answer(state); + await act(async () => + renderer!.update( + + + , + ), + ); +} + +beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host.data = answer("unviewed"); + host.refresh.mockReset(); + toastAdd.mockReset(); + setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("a mark whose file was pushed to before the read that followed it", () => { + it("gives way to the host and shows the file as changed", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { ...reference, files: [{ path: "a.ts", viewed: true }] }, + }); + expect(host.refresh).toHaveBeenCalled(); + + // The push landed between the write and this read, so the host answers `dismissed` rather + // than the `viewed` the press asked for. + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(false); + expect(view().isStale("a.ts")).toBe(true); + expect(view().viewedCount).toBe(0); + }); + + it("stays given way to on every later read, having nothing left to recover", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + await reads("dismissed"); + + view().refresh(); + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(false); + expect(view().isStale("a.ts")).toBe(true); + }); +}); + +describe("a mark the host has not answered for yet", () => { + it("holds the press while the write is still out", async () => { + let land = (_result: unknown) => {}; + setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve))); + + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + + // An answer already on its way when the box was ticked must not put it back. + await reads("unviewed"); + expect(view().isViewed("a.ts")).toBe(true); + + await act(async () => land(AsyncResult.success(undefined))); + expect(view().isViewed("a.ts")).toBe(true); + }); + + it("holds a press made since the read that would otherwise answer for it", async () => { + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + + // Pressed again before the post-write read came back. That press is the one on screen, and + // the read that answers for the first one says nothing about it. + view().setViewed("a.ts", true); + await reads("dismissed"); + + expect(view().isViewed("a.ts")).toBe(true); + expect(view().isStale("a.ts")).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts new file mode 100644 index 0000000000..c2ee3f4c0b --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -0,0 +1,211 @@ +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +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, + revertFileViewedOverlay, + settleFileViewedOverlay, + toFileViewedBatch, + toFileViewedStates, + type FileViewedOverlay, + type FileViewedStates, +} 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(); + +export interface PullRequestFilesViewedView { + /** Whether anything remembers this at all, which is what hides the whole control. */ + readonly enabled: boolean; + readonly isViewed: (path: string) => boolean; + /** 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; + /** The host had more files than the read covered, so the count above may be short. */ + readonly truncated: boolean; + /** + * Why the marks could not be read, when they could not. The boxes fall back to the last answer + * there was, or to empty when there has not been one, and neither of those says so on its own: + * a reader who sees every box unticked has no way to tell a fresh review from a failed read. + */ + readonly error: string | null; + /** Re-ask the host, for the page's refresh button, which goes around the host's cache. */ + readonly refresh: () => void; +} + +/** + * Which files this reader has already cleared. 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. Presses show + * immediately and are held over the server's answer until a read that could have seen them comes + * back, so the checkbox never waits on a round trip and never outlasts the record behind it. + */ +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; + // `query.data` holds the last answer through a failure, so the boxes stay where the host last + // put them rather than emptying under the reader; the error travels with them, because ticks + // that stopped being refreshed look exactly like ticks that are current. + const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const truncated = query.data?.truncated === true; + const error = query.error; + const [overlay, setOverlay] = useState(NO_OVERLAY); + 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. A path pressed again while its request is out belongs to the later request + // from then 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 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 + // 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); + + // The host's answer as it stood when a completed write was acknowledged, per path. The first + // answer that differs from it is the first read that could have seen the write, which is what + // retires the press rather than the host happening to agree with it. + const answeredFrom = useRef>(new Map()); + const statesRef = useRef(states); + statesRef.current = states; + + useEffect(() => { + const pending = new Set([...queued.current.keys(), ...sentBy.current.keys()]); + const answered = new Set(); + for (const [path, from] of answeredFrom.current) { + if (pending.has(path) || from === states) continue; + answered.add(path); + answeredFrom.current.delete(path); + } + setOverlay((current) => settleFileViewedOverlay(current, states, pending, answered)); + }, [states]); + + const flush = useCallback(() => { + flushTimer.current = null; + const batch = toFileViewedBatch(queued.current); + if (batch.length === 0) return; + queued.current = new Map(); + const sentFrom = scope.current; + const request = ++requests.current; + for (const file of batch) sentBy.current.set(file.path, request); + void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { + 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 on, 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. 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)); + // Silent when nothing was still this request's to answer for, so nothing on screen went + // back, and when 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; + } + // Answered for from the next read on, whatever it says. A push landing between the write + // and that read comes back as `dismissed`, and the press must not stand over it. + for (const path of mine) answeredFrom.current.set(path, statesRef.current); + 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; + + // 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(); + sentBy.current = new Map(); + answeredFrom.current = new Map(); + setOverlay(NO_OVERLAY); + }; + }, [scopeKey]); + + 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); + 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], + ); + + // One identity per change of what it says: the viewer keys every file it draws off this. + return useMemo( + () => ({ + enabled, + isViewed, + isStale, + setViewed, + viewedCount, + truncated, + error, + refresh: refreshFromHost, + }), + [enabled, error, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], + ); +} diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 5be46ca06f..7b8857f32a 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -367,3 +367,62 @@ describe("getDiffLineStat", () => { expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 }); }); }); + +describe("a file whose name a patch header cannot carry plainly", () => { + /** How git writes such a name, and so how every provider's patch arrives here. */ + const quotedPatch = (written: string) => + [ + `diff --git "a/${written}" "b/${written}"`, + "index 1111111..2222222 100644", + `--- "a/${written}"`, + `+++ "b/${written}"`, + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"); + + const pathOf = (patch: string) => { + const parsed = getRenderablePatch(patch, "review"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") throw new Error("patch did not parse as files"); + const file = parsed.files[0]; + expect(file).toBeDefined(); + if (!file) throw new Error("patch carried no file"); + return resolveFileDiffPath(file); + }; + + it("is the name the host knows, not the part of it before the tab", () => { + // The path is what a viewed mark, a review comment and a file read are all asked for by, so a + // name read short is a mark put on a path the host has never heard of. + expect(pathOf(quotedPatch("tab\\tfile.txt"))).toBe("tab\tfile.txt"); + }); + + it("is the name the host knows, not the part of it before the newline", () => { + expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt"); + }); + + it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => { + expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts"); + }); + + it("reads both sides of a rename under the names they really have", () => { + const patch = [ + 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"', + "similarity index 90%", + 'rename from "old\\tname.ts"', + 'rename to "new\\tname.ts"', + "", + ].join("\n"); + + const parsed = getRenderablePatch(patch, "review"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + const file = parsed.files[0]; + expect(file).toBeDefined(); + if (!file) return; + + expect(resolveFileDiffPath(file)).toBe("new\tname.ts"); + expect(resolveFileDiffPreviousPath(file)).toBe("old\tname.ts"); + }); +}); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 3063607a0d..c99349a7da 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,6 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import { parseDiffFromFile } from "@pierre/diffs"; import type { FileDiffMetadata } from "@pierre/diffs/types"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -199,8 +200,18 @@ export function getRenderablePatch( } } +/** + * What the patch called the file, as the file's own name. Git writes a name holding a tab, a + * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still + * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, + * and the host knows the file only under the name it really has. + */ +function fileDiffPath(raw: string): string { + return unquoteGitPatchPath(raw); +} + export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - return fileDiff.name ?? fileDiff.prevName ?? ""; + return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); } /** @@ -208,7 +219,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - return fileDiff.prevName ?? fileDiff.name ?? ""; + return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); } /** diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 538334dcc4..d4fee59441 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -12,6 +12,7 @@ import { shouldOpenPullRequestExternally, } from "./openPullRequestLink"; import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; function repositoryIdentity( provider: string, @@ -533,6 +534,32 @@ 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. + // + // Derived from the SSH remote the way the server derives it rather than written out, so the + // day that normalization stops reaching the web spelling this fails here too. + const canonicalKey = normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code"); + const projects = [ + project({ + canonicalKey, + provider: "azure-devops", + displayName: canonicalKey.split("/").slice(1).join("/"), + 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/docs/README.md b/docs/README.md index d1cf9139ff..6191222472 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Connection runtime](./internals/connection-runtime.md) - [Live update buffers](./internals/live-updates.md) - [Providers](./internals/providers.md) +- [Pull request file revisions](./internals/pull-request-file-revisions.md) - [Model classification](./internals/model-manifest.md) - [Exact conversation rollback](./internals/rollback-recovery.md) - [Remote environments](./internals/remote.md) diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md new file mode 100644 index 0000000000..3a84b8de66 --- /dev/null +++ b/docs/internals/pull-request-file-revisions.md @@ -0,0 +1,65 @@ +# Pull request file revisions + +On a host that keeps no viewed-file record of its own, this environment keeps the marks and asks +the host what version each marked file is at, so that a push can report a cleared file as changed. +That version travels through four layers, and a missing entry means something different at each +one. No type distinguishes those meanings and no single file states the whole path, which is what +this page is for. + +The failure is on record rather than hypothetical. The interface doc above +[GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said +the opposite of the code beneath it, because it described a missing path the way the layer below +means it. Anything written against that reading would treat a version the host declined to give as +a file the change request deleted, which reports every file a reader has cleared as changed across +a project the token cannot see. + +## An absence changes meaning at the provider boundary + +Below the boundary, in a patch parser or a response decoder, a missing path means this revision +does not carry that file. Above it, in +[`ProviderFileRevisions`](../../apps/server/src/pullRequest/PullRequestProvider.ts), a missing path +means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its +host looked at and has no version for arrives as the empty string, and absence is kept for what +the provider never got to look at. Absence below the boundary does not survive it. + +What "never got to look at" is belongs to the host, and the contract cannot know any of them: + +- [Azure DevOps](../../apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts) reads every + version off one iteration listing, so a change too long to follow to its end leaves the paths + past that point out. +- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the + pull request's own patch, the only place it states a file's version, so a patch cut short at the + byte ceiling leaves the paths past the cut out. +- [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch + it could not read leaves that batch's paths out. + +Those three facts stay with their providers. The contract states only that absence means the read +could not say, which is the half a provider must convert to. + +## Three nulls, and the empty string is not one of them + +The empty string is an answer: the host looked, and the head has no version, which is where a +deleted file sits. It compares equal to a mark stamped with it, so a mark taken against a deletion +is cleared once and stays cleared. Everything below is the absence of an answer, and the three +forms are not interchangeable. + +A null map, from the service's read of the whole scope, costs those marks their staleness. They +still report as cleared; they stop noticing pushes. It happens when the provider offers no +`getFileRevisions` at all, and, on either path, when the call failed and was logged. A press that +gets no answer does not refuse the reader's tick: it stores the mark with no baseline, which is +the third form below. + +A path absent from an answered map is the per-path case, and it is the one that must not be read +as a deletion. [`HeldFileRevisions`](../../apps/server/src/pullRequest/pullRequestViewedFiles.ts) +records what it has asked as well as what it heard, so an absent path keeps the last version given +for it instead of losing one. + +A null on the stored mark is the third, and the only one this environment invents. +[`PullRequestFileViewedMark`](../../apps/server/src/persistence/PullRequestFilesViewed.ts) stores +`revision` as nullable so a press that got no answer for a path can store no baseline at all. +Storing the empty string instead would report the file as changed the moment the host turned out +to have a version after all. A mark with no baseline reads as cleared until the reader presses it +again. + +Both read-side nulls collapse into that third one on the write path: whether the map was null or +the path was only absent from it, the press stores a mark with no baseline. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2675208a90..9bece28381 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -133,8 +133,22 @@ Enable **Settings → General → Proactive panels** to open linked pull request no linked review, completed changes to at least three files or 50 lines can open the working-tree diff automatically. Manual panel choices take priority. -For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support -reopening a declined pull request. +For Azure DevOps, use the host website to change comments. Bitbucket does not support reopening a +declined pull request. + +### Mark files as viewed + +Tick a file off in the **Code** tab once you have read it and it collapses; the toolbar keeps a +running count. A tick belongs to the pull request rather than to a commit, so scoping the tab to a +single commit keeps them. A file pushed to after you cleared it comes back marked **Changed**. + +On GitHub these are GitHub's own viewed marks, so a review carries between T3 Code and github.com +in either direction. Forgejo, GitLab, Bitbucket, and Azure DevOps expose no record T3 Code can read, so the +server you are connected to keeps them instead: they follow you across the apps connected to that +server, but the host's own site will not show them, and the count reads **viewed in T3 Code**. + +The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but +does not show its diff, so marks are made and read on web and desktop. ## Troubleshooting diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index bcc6341b5c..2e1df8c968 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -261,6 +261,25 @@ export function createPullRequestEnvironmentAtoms( ]), }, }), + filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:files-viewed", + tag: WS_METHODS.pullRequestsFilesViewed, + staleTimeMs: 15_000, + }), + /** + * One write 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.test.ts b/packages/contracts/src/pullRequest.test.ts index 4e39e0dcad..3945f083ac 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -6,10 +6,12 @@ import { ThreadPullRequestLink } from "./orchestration.ts"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestFilesViewedResult, PullRequestListInput, PullRequestListResult, PullRequestRef, PullRequestReviewerRequestInput, + PullRequestSetFilesViewedInput, pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -66,6 +68,8 @@ it("keeps an already-linked Forgejo thread decodable by the prior provider-neutr }; expect(decodeLegacyThreadLink(link)).toMatchObject(link); }); +const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput); +const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult); const LIST_RESULT: PullRequestListResult = { viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" }, @@ -322,3 +326,66 @@ describe("naming the reader as the author to narrow by", () => { expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me"); }); }); + +describe("naming the file a tick belongs to", () => { + // A space on either end of a name is part of the name as far as git is concerned. The patch on + // screen and the environment's record of what was cleared are both keyed by it, so a path + // tidied in transit ticks a file that does not exist and leaves the one on screen unticked. + it("keeps the spaces around a path being ticked", () => { + expect( + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: "docs/readme.md ", viewed: true }], + }).files, + ).toEqual([{ path: "docs/readme.md ", viewed: true }]); + }); + + it("keeps the spaces around a path being reported back", () => { + expect( + decodeFilesViewed({ + files: [{ path: " leading.md", state: "viewed" }], + truncated: false, + }).files, + ).toEqual([{ path: " leading.md", state: "viewed" }]); + }); + + it("still refuses a path that is nothing at all", () => { + expect(() => + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: "", viewed: true }], + }), + ).toThrow(); + }); + + it("refuses a batch larger than a reader can press", () => { + // Every element of a batch is a statement of its own inside one transaction on an + // environment-kept host, or a field of its own in one GraphQL document on GitHub, so what a + // client may send has to be bounded rather than trusted to be a burst of presses. + const press = (path: string) => ({ path, viewed: true }); + const batch = (count: number) => ({ + projectId: "p1", + repository: "group/project", + number: 7, + files: Array.from({ length: count }, (_, at) => press(`src/f${at}.ts`)), + }); + + expect(() => decodeSetFilesViewed(batch(500))).not.toThrow(); + expect(() => decodeSetFilesViewed(batch(501))).toThrow(); + }); + + it("refuses a path far longer than any real one", () => { + expect(() => + decodeSetFilesViewed({ + projectId: "p1", + repository: "group/project", + number: 7, + files: [{ path: `src/${"a".repeat(4096)}.ts`, viewed: true }], + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 32a52f2cde..77e143c58b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -371,6 +371,14 @@ 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 none, shared by clients of that environment. + */ +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. @@ -407,6 +415,11 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this field was. */ reactions: Schema.optional(Schema.Boolean), + /** + * 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`. + */ + viewedFiles: Schema.optional(PullRequestViewedFilesStore), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /** @@ -764,6 +777,8 @@ export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; */ export const PullRequestInvalidateInput = Schema.Struct({ reference: Schema.optional(PullRequestRef), + /** Refresh review progress in this environment without discarding the patch. */ + filesViewedOnly: Schema.optional(Schema.Boolean), }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; @@ -935,6 +950,66 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +/** + * Bounded because a path arrives from a client rather than from the host: one element of a write + * batch could otherwise carry a megabyte into a SQL statement or a GraphQL field. + */ +const MAX_FILE_PATH_LENGTH = 4096; +/** + * Not trimmed: a leading or trailing space is a legal part of a file's name, and the mark is + * keyed by the name the host gave, so trimming files it under a name nothing else uses. + */ +const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH)); + +/** + * Where one file of a change request stands with the person reading it. `dismissed` is the file + * that was cleared and has since been pushed to, which is worth telling a reader apart from + * `unviewed`: 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: FilePath, + state: PullRequestFileViewedState, +}); +export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; + +/** + * Which files of a change request the reader has cleared. Its own read rather than a field on the + * patch: a patch changes when somebody pushes and is cached by the minute, this changes on every + * press, so one read would have to be wrong for the other to be right. + */ +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, so the count is short and says so. */ + truncated: Schema.Boolean, +}); +export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type; + +/** + * How many presses one write carries. Every element is a statement of its own inside one + * transaction here, or a field of its own in one GraphQL document on GitHub. Matched to what a + * read of the marks carries, so a client cannot write more of them than it can ever read back. + */ +const MAX_FILES_VIEWED_PRESSES = 500; + +/** + * 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, so a burst is gathered into one request. + */ +export const PullRequestSetFilesViewedInput = Schema.Struct({ + ...PullRequestRef.fields, + files: Schema.Array( + Schema.Struct({ + path: FilePath, + viewed: Schema.Boolean, + }), + ).check(Schema.isMaxLength(MAX_FILES_VIEWED_PRESSES)), +}); +export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type; + export const PullRequestStackHead = Schema.Struct({ number: PositiveInt, headSha: TrimmedNonEmptyString, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 53f5c2e21f..7a78642bf1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -169,6 +169,7 @@ import { PullRequestDetail, PullRequestDiffFileContentsInput, PullRequestDiffFileContentsResult, + PullRequestFilesViewedResult, PullRequestInvalidateInput, PullRequestListInput, PullRequestListResult, @@ -184,6 +185,7 @@ import { PullRequestReviewerRequestInput, PullRequestLabelCandidateList, PullRequestLabelChangeInput, + PullRequestSetFilesViewedInput, PullRequestSubmitReviewInput, PullRequestThreadCommentsInput, PullRequestThreadCommentsResult, @@ -493,6 +495,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", @@ -1050,6 +1054,18 @@ const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFi error: PullRequestRpcError, }); +const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, { + payload: PullRequestRef, + success: PullRequestFilesViewedResult, + error: PullRequestRpcError, +}); + +const WsPullRequestsSetFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsSetFilesViewed, { + payload: PullRequestSetFilesViewedInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, @@ -1731,6 +1747,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc, + WsPullRequestsFilesViewedRpc, + WsPullRequestsSetFilesViewedRpc, WsPullRequestsRunActionRpc, WsPullRequestsUpdateRpc, WsPullRequestsCommentRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 73bbd85c31..cf17ffa1d0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -334,6 +334,14 @@ "./image": { "types": "./src/image.ts", "import": "./src/image.ts" + }, + "./cliRelease": { + "types": "./src/cliRelease.ts", + "import": "./src/cliRelease.ts" + }, + "./gitPatchPath": { + "types": "./src/gitPatchPath.ts", + "import": "./src/gitPatchPath.ts" } }, "scripts": { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 9d7ed52708..ce0defd058 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -50,6 +50,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("parseOriginUrlFromGitConfig", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 8e618d2d64..0c72795517 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -108,6 +108,25 @@ 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; + 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 +140,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 +155,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; diff --git a/packages/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts new file mode 100644 index 0000000000..20c5065729 --- /dev/null +++ b/packages/shared/src/gitPatchPath.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { quoteGitPatchPath, unquoteGitPatchPath } from "./gitPatchPath.ts"; + +const BELL = "\u0007"; +const UNIT_SEPARATOR = "\u001f"; +const DELETE = "\u007f"; + +describe("quoteGitPatchPath", () => { + it("leaves a name a header can carry as itself", () => { + expect(quoteGitPatchPath("src/app.ts")).toBe("src/app.ts"); + expect(quoteGitPatchPath("with space.txt")).toBe("with space.txt"); + // Outside ASCII is only quoted for what a terminal can show, and a diff viewer is not one. + expect(quoteGitPatchPath("café/résumé.ts")).toBe("café/résumé.ts"); + expect(quoteGitPatchPath("🚀.ts")).toBe("🚀.ts"); + }); + + it("writes git's escape for a character the header would read as its own", () => { + expect(quoteGitPatchPath("tab\tfile.txt")).toBe('"tab\\tfile.txt"'); + expect(quoteGitPatchPath("line\nfile.txt")).toBe('"line\\nfile.txt"'); + expect(quoteGitPatchPath('quo"te.txt')).toBe('"quo\\"te.txt"'); + expect(quoteGitPatchPath("back\\slash.txt")).toBe('"back\\\\slash.txt"'); + expect(quoteGitPatchPath(`ring${BELL}.txt`)).toBe('"ring\\a.txt"'); + }); + + it("writes the octal of a control character git has no name for", () => { + expect(quoteGitPatchPath(`unit${UNIT_SEPARATOR}.txt`)).toBe('"unit\\037.txt"'); + expect(quoteGitPatchPath(`del${DELETE}.txt`)).toBe('"del\\177.txt"'); + }); +}); + +describe("a name written into a header and read back out", () => { + const names = [ + "src/app.ts", + "with space.txt", + "café/résumé.ts", + "🚀.ts", + "tab\tfile.txt", + "line\nfile.txt", + "carriage\rfile.txt", + 'quo"te.txt', + "back\\slash.txt", + `ring${BELL}.txt`, + `unit${UNIT_SEPARATOR}.txt`, + `del${DELETE}.txt`, + `every\t\n\r"\\${BELL}.txt`, + ]; + + for (const name of names) { + it(`is the name that went in: ${JSON.stringify(name)}`, () => { + const written = quoteGitPatchPath(name); + expect(unquoteGitPatchPath(written)).toBe(name); + // A parser that takes the quotes off itself, as the clients' one does, gets there too. + const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; + expect(unquoteGitPatchPath(unwrapped)).toBe(name); + }); + } + + it("carries the whole name past the first thing a header stops at", () => { + const written = quoteGitPatchPath("tab\tfile.txt"); + + // The bug this guards: a header line ends at a tab and splits on a space, so the name written + // as itself would be read as `tab` and marked viewed under a path no host has. + expect(written).not.toContain("\t"); + expect(written).not.toContain("\n"); + expect(unquoteGitPatchPath(written)).not.toBe("tab"); + }); +}); + +describe("unquoteGitPatchPath", () => { + it("leaves a token git saw no need to quote alone", () => { + expect(unquoteGitPatchPath("src/app.ts")).toBe("src/app.ts"); + expect(unquoteGitPatchPath("with space.txt")).toBe("with space.txt"); + expect(unquoteGitPatchPath("")).toBe(""); + expect(unquoteGitPatchPath('"')).toBe('"'); + }); + + it("reads the octal escapes a host with core.quotePath on writes", () => { + expect(unquoteGitPatchPath('"caf\\303\\251/r\\303\\251sum\\303\\251.ts"')).toBe( + "café/résumé.ts", + ); + expect(unquoteGitPatchPath('"\\360\\237\\232\\200.ts"')).toBe("🚀.ts"); + }); +}); + +describe("a name a parser handed back with its quotes already off", () => { + it("is read for the escapes it still carries", () => { + expect(unquoteGitPatchPath("tab\\tfile.txt")).toBe("tab\tfile.txt"); + expect(unquoteGitPatchPath("caf\\303\\251.ts")).toBe("café.ts"); + }); + + it("reads an escape git would never write the way C reads it", () => { + expect(unquoteGitPatchPath("odd\\zname.txt")).toBe("oddzname.txt"); + expect(unquoteGitPatchPath("trailing\\")).toBe("trailing\\"); + expect(unquoteGitPatchPath("short\\12.txt")).toBe("short12.txt"); + }); +}); diff --git a/packages/shared/src/gitPatchPath.ts b/packages/shared/src/gitPatchPath.ts new file mode 100644 index 0000000000..767600c4a4 --- /dev/null +++ b/packages/shared/src/gitPatchPath.ts @@ -0,0 +1,140 @@ +/** + * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in + * a header by where it stops: `diff --git a/ b/` splits on a space, `--- a/` stops + * at a tab. So a name holding a tab or a newline reads back as the part of itself before that + * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted + * with C-style escapes. Both halves live here so what one writes is what the other reads. + */ + +/** + * What git escapes by name, as the character written and the escape written for it. Not every byte + * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for + * what a terminal can show rather than anything the format needs, and a patch here is read by a + * diff viewer. + */ +const ESCAPE_BY_CHARACTER = new Map([ + ['"', '\\"'], + ["\\", "\\\\"], + ["\u0007", "\\a"], + ["\b", "\\b"], + ["\t", "\\t"], + ["\n", "\\n"], + ["\v", "\\v"], + ["\f", "\\f"], + ["\r", "\\r"], +]); + +/** The escape written for a character, as the byte it stands for. */ +const CHARACTER_BY_ESCAPE: Record = { + '"': 0x22, + "\\": 0x5c, + a: 0x07, + b: 0x08, + f: 0x0c, + n: 0x0a, + r: 0x0d, + t: 0x09, + v: 0x0b, +}; + +const QUOTE = '"'; +const DELETE_CHARACTER = 0x7f; +const LOWEST_PRINTABLE = 0x20; +const BACKSLASH = 0x5c; + +const utf8 = new TextEncoder(); +const fromUtf8 = new TextDecoder(); + +/** + * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form + * where it is not, which is any name holding a quote, a backslash or a control character. A header + * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git + * quotes is the whole token a reader takes off the line, side letter and all. + */ +export function quoteGitPatchPath(path: string): string { + let body = ""; + let quoting = false; + for (const character of path) { + const escape = ESCAPE_BY_CHARACTER.get(character); + if (escape !== undefined) { + body += escape; + quoting = true; + continue; + } + const code = character.codePointAt(0) ?? 0; + // Every character git has no name for is written as the octal of its byte, and a control + // character is one byte in UTF-8, so the character's own code point is that byte. + if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) { + body += `\\${code.toString(8).padStart(3, "0")}`; + quoting = true; + continue; + } + body += character; + } + return quoting ? `${QUOTE}${body}${QUOTE}` : path; +} + +/** + * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name + * holding no backslash is already itself, and an escape git would never write reads the way C + * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and + * only reads back as itself once those bytes are rejoined and decoded together. + */ +function unescapeBody(body: string): string { + if (!body.includes("\\")) return body; + const bytes: Array = []; + // Anything left as itself is encoded a run at a time rather than a unit at a time, so a + // character outside the basic plane keeps its pair together rather than coming back as halves. + let literal = ""; + const flush = () => { + if (literal.length === 0) return; + bytes.push(...utf8.encode(literal)); + literal = ""; + }; + let at = 0; + while (at < body.length) { + const character = body.charAt(at); + if (character !== "\\") { + literal += character; + at += 1; + continue; + } + const escaped = body.charAt(at + 1); + if (escaped === "") { + flush(); + bytes.push(BACKSLASH); + break; + } + const named = CHARACTER_BY_ESCAPE[escaped]; + if (named !== undefined) { + flush(); + bytes.push(named); + at += 2; + continue; + } + const octal = body.slice(at + 1, at + 4); + if (/^[0-7]{3}$/.test(octal)) { + flush(); + bytes.push(Number.parseInt(octal, 8)); + at += 4; + continue; + } + literal += escaped; + at += 2; + } + flush(); + return fromUtf8.decode(new Uint8Array(bytes)); +} + +/** + * One header's name token as the name it stands for. The escapes are undone whether the quotes are + * still there or not, because patch parsers disagree about how much of the quoting they hand back: + * the one the clients read diffs with takes the quotes off the `diff --git` line's names and + * leaves them on a rename's. + */ +export function unquoteGitPatchPath(token: string): string { + if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) { + return unescapeBody(token.slice(1, -1)); + } + return unescapeBody(token); +} diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index f9c009fac0..d6c3bc5e1f 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -208,3 +208,17 @@ it("keeps a GitLab identity's whole path, because a nested group is part of the }); expect(selector).toBe("group/subgroup/service"); }); + +it("puts owner and name back together for an identity recorded before displayName", () => { + const selector = sourceControlRepositorySelector({ + provider: "github", + owner: "t3tools", + name: "t3code", + }); + expect(selector).toBe("t3tools/t3code"); +}); + +it("names nothing for a project with no remote to name it by", () => { + expect(sourceControlRepositorySelector(null)).toBeNull(); + expect(sourceControlRepositorySelector({ provider: "github" })).toBeNull(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbdf753057..58d72d9034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -538,6 +538,9 @@ importers: '@sigstore/verify': specifier: 3.0.0 version: 3.0.0 + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) From e53f289fe13e021a58363140a597d91793284509 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:06:42 -0600 Subject: [PATCH 02/16] Bind viewed-file marks to displayed diff sections and current account --- .../Migrations/063_PullRequestFilesViewed.ts | 1 + .../src/persistence/PullRequestFilesViewed.ts | 11 +- .../pullRequest/PullRequestService.test.ts | 391 ++++++++++++++---- .../src/pullRequest/PullRequestService.ts | 355 ++++++++-------- .../pullRequestPatchDigests.test.ts | 61 +++ .../pullRequest/pullRequestPatchDigests.ts | 87 ++++ .../src/pullRequest/pullRequestViewedFiles.ts | 153 ++++++- .../pullRequest/PullRequestCodeTab.tsx | 25 +- .../pullRequestFilesViewed.logic.ts | 16 +- .../usePullRequestFilesViewed.test.tsx | 40 +- .../pullRequest/usePullRequestFilesViewed.ts | 94 ++++- .../client-runtime/src/state/pullRequests.ts | 8 +- packages/contracts/src/pullRequest.ts | 10 + 13 files changed, 932 insertions(+), 320 deletions(-) create mode 100644 apps/server/src/pullRequest/pullRequestPatchDigests.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestPatchDigests.ts diff --git a/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts b/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts index 5e7f67c0b1..ea82ad83c4 100644 --- a/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/Migrations/063_PullRequestFilesViewed.ts @@ -18,6 +18,7 @@ export default Effect.gen(function* () { viewer TEXT NOT NULL, path TEXT NOT NULL, revision TEXT, + display_digest TEXT, 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 index 9904e24401..9f884a8629 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -41,6 +41,8 @@ export const PullRequestFileViewedMark = Schema.Struct({ * `docs/internals/pull-request-file-revisions.md`. */ revision: Schema.NullOr(Schema.String), + /** Digest of the exact file section shown when this mark was made. */ + displayDigest: Schema.NullOr(Schema.String), }); export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; @@ -99,7 +101,8 @@ const make = Effect.gen(function* () { sql` SELECT path AS "path", - revision AS "revision" + revision AS "revision", + display_digest AS "displayDigest" FROM pull_request_files_viewed WHERE provider = ${provider} AND host = ${host} @@ -146,6 +149,7 @@ const make = Effect.gen(function* () { viewer, path, revision, + display_digest, viewed_at ) VALUES ( @@ -156,10 +160,13 @@ const make = Effect.gen(function* () { ${input.viewer}, ${file.path}, ${file.revision}, + ${file.displayDigest}, ${input.viewedAt} ) ON CONFLICT (provider, host, repository, number, viewer, path) - DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at + DO UPDATE SET revision = excluded.revision, + display_digest = excluded.display_digest, + viewed_at = excluded.viewed_at ` : sql` DELETE FROM pull_request_files_viewed diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 414fecbab1..7491370d25 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4958,6 +4958,8 @@ it.effect("names the signed-in account in the detail, and says nothing where the }), ); +const TEST_DISPLAY_DIGEST = "a".repeat(64); + it.effect("returns large diff slices intact without retaining them in either cache", () => Effect.gen(function* () { let reads = 0; @@ -4985,11 +4987,13 @@ it.effect("returns large diff slices intact without retaining them in either cac patch, truncated: false, nextCursor: "2", + fileDigests: [], }); assert.deepStrictEqual(yield* service.diff(input), { patch, truncated: false, nextCursor: "2", + fileDigests: [], }); assert.strictEqual(reads, before + 2); } @@ -5083,6 +5087,202 @@ it.effect("keeps the diff cached across a file being ticked off", () => }), ); +it.effect("serializes host-backed presses on the same pull request", () => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const calls: boolean[] = []; + const patch = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1 +1 @@ +-before ++after +`; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "project", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "host", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => Effect.succeed({ patch, truncated: false, nextCursor: null }), + setFilesViewed: ({ files }) => + Effect.gen(function* () { + calls.push(files[0]!.viewed); + if (files[0]!.viewed) { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(releaseFirst); + } + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const digest = (yield* service.diff(reference)).fileDigests?.[0]?.digest; + assert.isString(digest); + const first = yield* service + .setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: true, digest }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstStarted); + const second = yield* service + .setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.deepStrictEqual(calls, [true]); + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.deepStrictEqual(calls, [true, false]); + }), +); + +it.effect("rejects a host mark when the displayed file section changed before the press", () => + Effect.gen(function* () { + let patch = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1 +1 @@ +-before ++first +`; + let writes = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "project", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "host", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => Effect.succeed({ patch, truncated: false, nextCursor: null }), + setFilesViewed: () => + Effect.sync(() => { + writes += 1; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const shown = (yield* service.diff(reference)).fileDigests?.[0]?.digest; + assert.isString(shown); + patch = patch.replace("+first", "+second"); + const error = yield* service + .setFilesViewed({ + ...reference, + files: [{ path: "src/a.ts", viewed: true, digest: shown }], + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(writes, 0); + }), +); + +it.effect( + "keeps environment viewed marks under the current account after a credential switch", + () => + Effect.gen(function* () { + let account = "alice"; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), + getViewer: () => Effect.sync(() => account), + }, + ], + }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }); + account = "bob"; + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, []); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + ]); + account = "alice"; + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + ]); + }), +); + +it.effect("rejects an environment mark when the account changes during revision lookup", () => + Effect.gen(function* () { + let account = "alice"; + const revisionStarted = yield* Deferred.make(); + const releaseRevision = yield* Deferred.make(); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map(), []), + getViewer: () => Effect.sync(() => account), + getFileRevisions: () => + Deferred.succeed(revisionStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRevision)), + Effect.as({ revisions: new Map([["src/a.ts", "blob-a"]]) }), + ), + }, + ], + }); + const press = yield* service + .setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(revisionStarted); + account = "bob"; + yield* Deferred.succeed(releaseRevision, undefined); + const error = yield* Fiber.join(press); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, []); + account = "alice"; + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, []); + }), +); + const environmentViewedProvider = ( revisions: Map, asked: Array>, @@ -5211,7 +5411,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"]; yield* service.setFilesViewed({ ...reference, - files: paths.map((path) => ({ path, viewed: true })), + files: paths.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); assert.deepStrictEqual( new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), @@ -5237,14 +5437,14 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba yield* service.invalidate({ reference }); yield* service.setFilesViewed({ ...reference, - files: [{ path: "beta.ts", viewed: true }], + files: [{ path: "beta.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); truncated = false; yield* service.invalidate({ reference }); // A partial response must not stamp an empty revision and then dismiss the mark on recovery. assert.deepStrictEqual( (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"), - { path: "beta.ts", state: "viewed" }, + { path: "beta.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ); }), ); @@ -5283,13 +5483,13 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi number: 1, }; const second = { ...first, repository: "reviewer/second" }; - const files = [{ path: "same.ts", viewed: true }]; + const files = [{ path: "same.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }]; yield* service.setFilesViewed({ ...first, files }); assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); yield* service.setFilesViewed({ ...second, files }); yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ - { path: "same.ts", state: "viewed" }, + { path: "same.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); projects.push( @@ -5305,7 +5505,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi ); assert.deepStrictEqual( (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], + [{ path: "same.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }], ); projects.push( project({ @@ -5320,7 +5520,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi ); assert.deepStrictEqual( (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], + [{ path: "same.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }], ); }), ); @@ -5344,8 +5544,8 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: true }, + { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, ], }); const marked = yield* service.filesViewed(GITLAB_REFERENCE); @@ -5353,8 +5553,8 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () assert.deepStrictEqual( [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); assert.strictEqual(marked.truncated, false); @@ -5367,7 +5567,7 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () }), ); -it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () => +it.effect("reads a fresh version for each tick even when a whole-change answer is held", () => Effect.gen(function* () { const asked: Array> = []; const head = new Map([ @@ -5399,22 +5599,22 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); - // A path nothing has asked about before, which is what every tick after the first names. Its - // version came back with the first answer, so there is nothing left to read it for. + // The second path came back with the first answer. A press still rereads current host state + // instead of stamping a held version after the head may have moved. yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); - assert.deepStrictEqual(asked, [["src/a.ts"]]); + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); 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" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); @@ -5423,9 +5623,9 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth yield* TestClock.adjust("2 minutes"); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); + assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"], ["src/b.ts"]]); }), ); @@ -5436,13 +5636,15 @@ it.effect("reads the marks without asking the host what the head has every time" yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // 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(marked.files, [ + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + ]); assert.deepStrictEqual(asked, [["src/a.ts"]]); }), ); @@ -5455,20 +5657,24 @@ it.effect("answers the marks from what it last heard while it asks the host agai yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); 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.deepStrictEqual(held.files, [ + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + ]); 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" }]); + assert.deepStrictEqual(caught.files, [ + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, + ]); // The refresh behind the previous answer is the one that heard about the push. assert.strictEqual(asked.length, 2); }), @@ -5487,12 +5693,12 @@ it.effect("asks the host about a file it has not been asked about before", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("20 seconds"); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("20 seconds"); const marked = yield* service.filesViewed(GITLAB_REFERENCE); @@ -5500,8 +5706,8 @@ it.effect("asks the host about a file it has not been asked about before", () => assert.deepStrictEqual( [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); // The second press paid for its own file; the read that follows was already covered. @@ -5520,7 +5726,7 @@ it.effect("does not let a press about one file keep another file's version alive yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("40 seconds"); // This press asks about its own file and carries the other one forward untouched. Counting @@ -5528,7 +5734,7 @@ it.effect("does not let a press about one file keep another file's version alive // had almost aged out of, and a reader working down a long diff renews it press after press. yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); @@ -5543,8 +5749,8 @@ it.effect("does not let a press about one file keep another file's version alive assert.deepStrictEqual( [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/b.ts", state: "viewed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); }), @@ -5561,8 +5767,8 @@ it.effect("reports a file pushed to since it was cleared as changed", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: true }, + { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, ], }); revisions.set("src/a.ts", "blob-a-again"); @@ -5573,8 +5779,8 @@ it.effect("reports a file pushed to since it was cleared as changed", () => assert.deepStrictEqual( [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/b.ts", state: "viewed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); }), @@ -5587,7 +5793,7 @@ it.effect("clears a mark again when the file is put back", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, @@ -5607,12 +5813,14 @@ it.effect("keeps a deleted file cleared, which the head has no version of at all yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/gone.ts", viewed: true }], + files: [{ path: "src/gone.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* service.invalidate({ reference: GITLAB_REFERENCE }); const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]); + assert.deepStrictEqual(marked.files, [ + { path: "src/gone.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + ]); }), ); @@ -5633,8 +5841,8 @@ it.effect("leaves a mark alone when the host could not say what the head has of yield* service.setFilesViewed({ ...GITLAB_REFERENCE, files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/past-the-cut.ts", viewed: true }, + { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, + { path: "src/past-the-cut.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, ], }); revisions.set("src/a.ts", "blob-a-again"); @@ -5644,8 +5852,8 @@ it.effect("leaves a mark alone when the host could not say what the head has of 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" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/past-the-cut.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); }), @@ -5663,13 +5871,13 @@ it.effect("keeps a file cleared that the press could not learn a version for", ( yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/past-the-cut.ts", viewed: true }], + files: [{ path: "src/past-the-cut.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); unreadable.delete("src/past-the-cut.ts"); yield* service.invalidate({ reference: GITLAB_REFERENCE }); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/past-the-cut.ts", state: "viewed" }, + { path: "src/past-the-cut.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -5683,12 +5891,12 @@ it.effect("keeps the version it last heard when a later read of the head stops s yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); revisions.set("src/a.ts", "blob-a-again"); yield* service.invalidate({ reference: GITLAB_REFERENCE }); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, ]); // The read behind the next answer has to stop before this file. Forgetting the version it was @@ -5700,7 +5908,7 @@ it.effect("keeps the version it last heard when a later read of the head stops s yield* TestClock.adjust("20 seconds"); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -5712,7 +5920,7 @@ it.effect("re-asks what the head has of a marked file after a whole-workspace re yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // A push nobody told this environment about. No single reference has moved, so the held // answer goes only because the refresh is the reader asking for all of it to be read again. @@ -5720,7 +5928,7 @@ it.effect("re-asks what the head has of a marked file after a whole-workspace re yield* service.invalidate({}); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -5732,7 +5940,7 @@ it.effect("forgets what the head had of a marked file once a mutation moves the yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // Merging moves the head under the mark, and nobody asks for the refresh: the mutation is // the thing that knows, so it drops what it was holding rather than waiting to be told. @@ -5740,7 +5948,7 @@ it.effect("forgets what the head had of a marked file once a mutation moves the yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" }); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, + { path: "src/a.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -5785,7 +5993,7 @@ it.effect("still reports its own marks when the host will not say what the head yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); answering = false; yield* service.invalidate({ reference: GITLAB_REFERENCE }); @@ -5793,7 +6001,7 @@ it.effect("still reports its own marks when the host will not say what the head // The rows are this environment's own. A rate limit or a signed-out CLI costs them the // staleness they would have carried, not the reader's whole record of what they have read. assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -5840,7 +6048,7 @@ it.effect("finishes two presses on one file in the order they were made", () => const tick = service .setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }) .pipe(Effect.runFork); // Far enough for the tick to be waiting on the host rather than still on its way there. @@ -5917,11 +6125,11 @@ it.effect("keeps the marks of two Azure repositories of the same name apart", () yield* service.setFilesViewed({ ...AZURE_PLATFORM, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ - { path: "src/a.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); }), @@ -5933,7 +6141,7 @@ it.effect("keeps environment marks apart from another change request's", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); @@ -5952,6 +6160,7 @@ it.effect("bounds the paths one change request's held revisions carry", () => Array.from({ length: count }, (_, index) => ({ path: `${prefix}/${String(index).padStart(4, "0")}.ts`, viewed: true, + digest: TEST_DISPLAY_DIGEST, })); const press = (prefix: string, count: number) => service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); @@ -6006,7 +6215,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: ticked.map((path) => ({ path, viewed: true })), + files: ticked.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); for (const path of ticked) head.set(path, "blob-moved"); // Past the stale window, so the read is answered by the host rather than from what the press @@ -6017,25 +6226,24 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c assert.deepStrictEqual( [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), [ - { path: "src/f0000.ts", state: "dismissed" }, - { path: "src/f0500.ts", state: "dismissed" }, + { path: "src/f0000.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/f0500.ts", state: "dismissed", digest: TEST_DISPLAY_DIGEST }, ], ); }), ); -it.effect("keeps the change request being ticked through, not the one pressed first", () => +it.effect("bounds held revision scopes while fresh presses continue to read the host", () => Effect.gen(function* () { - // Ordered by insertion alone a hit does not renew its entry, so the review a reader is - // working down is the first thing dropped once a cache's worth of other change requests have - // been pressed, and the next press on it pays a host read for a version already held. + // Presses always refresh the head before storing a baseline. Each of the repeated presses + // must still have a bounded cache entry for later badge reads. const asked: Array> = []; const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); const press = (number: number) => service.setFilesViewed({ ...GITLAB_REFERENCE, number, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* press(1); @@ -6045,7 +6253,7 @@ it.effect("keeps the change request being ticked through, not the one pressed fi yield* press(1); } - assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); + assert.strictEqual(asked.length, 1 + 2 * FILE_REVISIONS_CACHE_CAPACITY); }), ); @@ -6076,16 +6284,16 @@ it.effect("keeps one reader's marks on a host that names nobody", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); -it.effect("puts a listing and a press for one host on a single viewer lookup", () => +it.effect("rechecks the viewed mark account separately from a listing lookup", () => Effect.gen(function* () { let viewerLookups = 0; const service = yield* makeService({ @@ -6114,13 +6322,13 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", ( }); // What a cold page load does: read the listing and the reader's own marks at the same time. - // Nothing about which of them asked is in the lookup's key, so they wait on one CLI between - // them rather than starting one each. + // The mark lookup verifies its account at the end as well, so a switch during the read cannot + // return another account's marks. Listing retains its own display viewer lookup. yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { concurrency: 2, }); - assert.strictEqual(viewerLookups, 1); + assert.strictEqual(viewerLookups, 2); }), ); @@ -6138,7 +6346,7 @@ it.effect("carries a bounded number of its own marks and says it held more", () yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: paths.map((path) => ({ path, viewed: true })), + files: paths.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); const read = yield* service.filesViewed(GITLAB_REFERENCE); @@ -6189,12 +6397,12 @@ it.effect("records a press while the host is backing off", () => yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("11 minutes"); yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // Nothing about a host holding its reads off says the reader did not press these, and these @@ -6205,11 +6413,11 @@ it.effect("records a press while the host is backing off", () => left.path.localeCompare(right.path), ), [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, + { path: "src/b.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ], ); - assert.strictEqual(viewerLookups, 2); + assert.strictEqual(viewerLookups, 8); }), ); @@ -6254,9 +6462,9 @@ it.effect("asks who is reading through a pause only for the press that is waitin yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); - assert.strictEqual(viewerLookups, 1); + assert.strictEqual(viewerLookups, 3); yield* TestClock.adjust("11 minutes"); // A listing is not the reader waiting on this lookup, and a failed one is held nowhere, so @@ -6264,15 +6472,15 @@ it.effect("asks who is reading through a pause only for the press that is waitin // lasts and re-extend it each time. const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); assert.strictEqual(listed._tag, "PullRequestOperationError"); - assert.strictEqual(viewerLookups, 1); + assert.strictEqual(viewerLookups, 3); // The press is bounded by what the reader does, and its rows are keyed by who they are, so // it is asked rather than refused. yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); - assert.strictEqual(viewerLookups, 2); + assert.strictEqual(viewerLookups, 6); }), ); @@ -6353,7 +6561,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // Who is signed in is held for ten minutes, so the lookup has to come round again before a // failing CLI can reach the read at all. @@ -6366,7 +6574,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( const write = yield* Effect.flip( service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }), ); assert.strictEqual(read._tag, "PullRequestOperationError"); @@ -6374,7 +6582,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( answering = true; assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, + { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); }), ); @@ -6402,7 +6610,10 @@ it.effect("refuses to track viewed files on a host that does not", () => const read = yield* Effect.flip(service.filesViewed(reference)); const write = yield* Effect.flip( - service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), + service.setFilesViewed({ + ...reference, + files: [{ path: "a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }), ); assert.strictEqual(read._tag, "PullRequestOperationError"); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a1326c3b65..f9afeb2bef 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -82,6 +82,7 @@ import { import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; import * as ViewedFiles from "./pullRequestViewedFiles.ts"; +import { fileDigestsFromPatch } from "./pullRequestPatchDigests.ts"; export interface PullRequestMergeEvent extends PullRequestRef { readonly mergedAt: string; @@ -135,7 +136,6 @@ const LIST_STATS_CACHE_TTL = Duration.seconds(60); * 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); /** 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. */ @@ -151,7 +151,6 @@ const DIFF_CACHE_CAPACITY = 128; const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024; const canCacheDiff = (value: PullRequestDiffResult) => value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES; -const FILES_VIEWED_CACHE_CAPACITY = 128; const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; @@ -1261,21 +1260,19 @@ export const make = Effect.gen(function* () { }), // One unreachable repository must not blank the page. A host-level failure is // already reported through `providers`, so it degrades the same way here. - Effect.orElseSucceed( - (): RepositoryBatch => ({ - key, - entries: [], - errors: [ - { - projectId: project.project.id, - projectTitle: project.project.title, - message: `${project.repository} could not be read.`, - }, - ], - truncated: false, - nextCursor: null, - }), - ), + Effect.orElseSucceed((): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + })), ); } }; @@ -1458,43 +1455,41 @@ export const make = Effect.gen(function* () { return read.pipe( Effect.mapError(toPullRequestError("summary")), observeRead, - Effect.map( - ({ value: changeRequest, observedAt }): PullRequestSummary => ({ - provider: project.api.kind, - projectId: project.project.id, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - url: changeRequest.url, - state: changeRequest.state, - headBranch: changeRequest.headBranch, - baseBranch: changeRequest.baseBranch, - closedAt: changeRequest.closedAt ?? null, - mergedAt: changeRequest.mergedAt ?? null, - updatedAt: changeRequest.updatedAt, - observedAt, - ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), - ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), - ...(changeRequest.additions === undefined - ? {} - : { additions: changeRequest.additions }), - ...(changeRequest.deletions === undefined - ? {} - : { deletions: changeRequest.deletions }), - ...(changeRequest.changedFiles === undefined - ? {} - : { changedFiles: changeRequest.changedFiles }), - ...(changeRequest.reviewDecision === undefined - ? {} - : { reviewDecision: changeRequest.reviewDecision }), - ...(changeRequest.checksState === undefined - ? {} - : { checksState: changeRequest.checksState }), - ...(changeRequest.mergeability === undefined - ? {} - : { mergeability: changeRequest.mergeability }), - }), - ), + Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, + updatedAt: changeRequest.updatedAt, + observedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), + })), ); }), ); @@ -1549,56 +1544,54 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ).pipe( - Effect.map( - ([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ - provider: project.api.kind, - capabilities: project.api.capabilities, - projectId: project.project.id, - projectTitle: project.project.title, - workspaceRoot: project.project.workspaceRoot, - repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - body: changeRequest.body, - url: changeRequest.url, - author: changeRequest.author, - state: changeRequest.state, - isDraft: changeRequest.isDraft, - mergeability: changeRequest.mergeability, - additions: changeRequest.additions, - deletions: changeRequest.deletions, - changedFiles: changeRequest.changedFiles, - headBranch: changeRequest.headBranch, - ...(changeRequest.headRepositoryNameWithOwner === undefined - ? {} - : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), - baseBranch: changeRequest.baseBranch, - createdAt: changeRequest.createdAt, - updatedAt: changeRequest.updatedAt, - observedAt, - mergedAt: changeRequest.mergedAt, - closedAt: changeRequest.closedAt, - reviewers: changeRequest.reviewers, - labels: changeRequest.labels, - checks: changeRequest.checks, - mergeCapabilities: changeRequest.mergeCapabilities, - viewerPermissions: changeRequest.viewerPermissions, - ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), - ...(changeRequest.baseComparison === undefined - ? {} - : { baseComparison: changeRequest.baseComparison }), - ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), - ...(changeRequest.autoMergeEnabled === undefined - ? {} - : { autoMergeEnabled: changeRequest.autoMergeEnabled }), - ...(changeRequest.autoMergeMethod === undefined - ? {} - : { autoMergeMethod: changeRequest.autoMergeMethod }), - ...(changeRequest.workflowApprovalsRequired === undefined - ? {} - : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), - }), - ), + Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + ...(changeRequest.headRepositoryNameWithOwner === undefined + ? {} + : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + observedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + ...(changeRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.workflowApprovalsRequired === undefined + ? {} + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), + })), ), ), ); @@ -1615,18 +1608,16 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("activity")), - Effect.map( - (activity): PullRequestActivity => ({ - ...(activity.author === undefined ? {} : { author: activity.author }), - ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), - comments: activity.comments, - commentCount: activity.commentCount, - commentsTruncated: activity.commentsTruncated, - reviewThreads: activity.reviewThreads, - commits: activity.commits, - ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), - }), - ), + Effect.map((activity): PullRequestActivity => ({ + ...(activity.author === undefined ? {} : { author: activity.author }), + ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), + comments: activity.comments, + commentCount: activity.commentCount, + commentsTruncated: activity.commentsTruncated, + reviewThreads: activity.reviewThreads, + commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + })), ), ), ); @@ -1669,7 +1660,23 @@ export const make = Effect.gen(function* () { ...(input.cursor === undefined ? {} : { cursor: input.cursor }), ...(input.commit === undefined ? {} : { commit: input.commit }), }) - .pipe(Effect.mapError(toPullRequestError("diff"))) + .pipe( + Effect.map((slice) => ({ + ...slice, + fileDigests: fileDigestsFromPatch( + slice.patch, + slice.truncated, + { + provider: project.api.kind, + host: project.host, + remote: project.remote, + number: input.number, + }, + new Set(slice.omittedFileStats?.map((file) => file.path) ?? []), + ), + })), + Effect.mapError(toPullRequestError("diff")), + ) : Effect.fail( new PullRequestOperationError({ operation: "diff", @@ -1718,27 +1725,37 @@ export const make = Effect.gen(function* () { const requiredViewerOf = ( project: SupportedProject, operation: string, - ): Effect.Effect => - resolveViewers([project], new Map(), { allowPaused: true }).pipe( - Effect.flatMap(([resolved]) => { - const error = resolved?.error ?? null; - return error === null - ? Effect.succeed(resolved?.viewer ?? null) - : Effect.fail(toPullRequestError(operation)(error)); - }), - ); + ): Effect.Effect => { + const registered = registry.get(project.api.kind); + if (registered === null) { + return Effect.fail( + new PullRequestOperationError({ + operation, + detail: "The pull request host is unavailable in this environment.", + }), + ); + } + // The display cache is keyed by host, not by account. Persisted marks need the current + // account on every operation, including after a credential switch on the same host. + return withRateLimitBackoff(registered, project.host, rateLimits, true) + .getViewer({ cwd: project.project.workspaceRoot, host: project.host }) + .pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((viewer) => + viewer === null + ? Effect.fail( + new PullRequestOperationError({ + operation, + detail: "The signed-in account could not be verified for viewed files.", + }), + ) + : Effect.succeed(viewer), + ), + ); + }; const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) => - canonicalRef(input).pipe( - Effect.flatMap((ref) => - viewedFiles.setFilesViewed(input).pipe( - // 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(ref))), - ), - ), - ); + canonicalRef(input).pipe(Effect.flatMap(() => viewedFiles.setFilesViewed(input))); const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( @@ -2264,23 +2281,22 @@ export const make = Effect.gen(function* () { ); } return viewerPermissionsOf(project, input, "setLabels").pipe( - Effect.flatMap( - (viewer): Effect.Effect => - viewer.labels === false - ? Effect.fail( - new PullRequestOperationError({ - operation: "setLabels", - detail: LABEL_CHANGE_REFUSAL, - }), - ) - : change({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - labels: input.labels, - applied: input.applied, - }).pipe(Effect.mapError(toPullRequestError("setLabels"))), + Effect.flatMap((viewer): Effect.Effect => + viewer.labels === false + ? Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : change({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }).pipe(Effect.mapError(toPullRequestError("setLabels"))), ), ); }), @@ -2536,12 +2552,6 @@ export const make = Effect.gen(function* () { 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); - /** Bumped by a whole-workspace refresh, the one drop no single reference's epoch covers. */ let everyFileRevisionEpoch = 0; // Built after the epochs because it reads two of them: taken as an argument any higher, @@ -2858,24 +2868,10 @@ export const make = Effect.gen(function* () { return staleDiff(key, read); }; - const filesViewedCache = yield* Cache.makeWith( - (key: string) => { - const [referenceKey] = JSON.parse(key) as [string, number]; - return viewedFiles.filesViewed(refOfCacheKey(referenceKey)); - }, - { - capacity: FILES_VIEWED_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), - }, - ); - // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, - // so a reference keyed as the client spelled it would never see a refresh or a press. + // An account can switch without the repository or host changing. Recheck the current reader + // on every request rather than serving another account's marks from a reference-only cache. const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => - canonicalRef(input).pipe( - Effect.flatMap((ref) => - Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), - ), - ); + canonicalRef(input).pipe(Effect.flatMap((ref) => viewedFiles.filesViewed(ref))); const listStatsCache = yield* Cache.makeWith( (key: string) => { @@ -2937,12 +2933,7 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (input.filesViewedOnly === true) { - return reference === undefined - ? Cache.invalidateAll(filesViewedCache) - : canonicalRef(reference).pipe( - Effect.flatMap((ref) => Effect.sync(() => bumpFilesViewedEpoch(ref))), - Effect.ignore, - ); + return reference === undefined ? Effect.void : canonicalRef(reference).pipe(Effect.ignore); } if (reference !== undefined) { return canonicalRef(reference).pipe( diff --git a/apps/server/src/pullRequest/pullRequestPatchDigests.test.ts b/apps/server/src/pullRequest/pullRequestPatchDigests.test.ts new file mode 100644 index 0000000000..fe7d309029 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestPatchDigests.test.ts @@ -0,0 +1,61 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { fileDigestsFromPatch } from "./pullRequestPatchDigests.ts"; + +const scope = { + provider: "gitlab", + host: "git.example", + remote: "https://git.example/group/app", + number: 7, +}; + +const first = `diff --git a/src/a.ts b/src/a.ts +index 111..222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1 +1 @@ +-old ++new +`; +const second = `diff --git a/src/b.ts b/src/b.ts +index 333..444 100644 +--- a/src/b.ts ++++ b/src/b.ts +@@ -1 +1 @@ +-before ++after +`; + +it.effect("binds a digest to one displayed file and repository while preserving page moves", () => + Effect.sync(() => { + const together = fileDigestsFromPatch(first + second, false, scope); + const moved = fileDigestsFromPatch(second, false, scope); + assert.strictEqual(together[1]?.digest, moved[0]?.digest); + assert.notStrictEqual( + together[0]?.digest, + fileDigestsFromPatch(first.replace("+new", "+changed"), false, scope)[0]?.digest, + ); + assert.notStrictEqual( + together[0]?.digest, + fileDigestsFromPatch(first, false, { ...scope, host: "other.example" })[0]?.digest, + ); + }), +); + +it.effect("withholds tokens for incomplete, binary, and duplicate path sections", () => + Effect.sync(() => { + assert.deepStrictEqual(fileDigestsFromPatch(first, true, scope), []); + assert.deepStrictEqual(fileDigestsFromPatch(first.replace("+new\n", ""), false, scope), []); + assert.deepStrictEqual( + fileDigestsFromPatch( + "diff --git a/image.png b/image.png\nBinary files a/image.png and b/image.png differ\n", + false, + scope, + ), + [], + ); + assert.deepStrictEqual(fileDigestsFromPatch(first + first, false, scope), []); + assert.deepStrictEqual(fileDigestsFromPatch(first, false, scope, new Set(["src/a.ts"])), []); + }), +); diff --git a/apps/server/src/pullRequest/pullRequestPatchDigests.ts b/apps/server/src/pullRequest/pullRequestPatchDigests.ts new file mode 100644 index 0000000000..460403887c --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestPatchDigests.ts @@ -0,0 +1,87 @@ +import * as NodeCrypto from "node:crypto"; +import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; + +export interface PatchDigestScope { + readonly provider: string; + readonly host: string; + readonly remote: string; + readonly number: number; +} + +/** A section cut off mid-hunk cannot prove what the reader saw of that file. */ +function hasCompleteHunks(section: string): boolean { + const lines = section.split("\n"); + let inHunk = false; + let hunks = 0; + let oldLeft = 0; + let newLeft = 0; + for (const [index, line] of lines.entries()) { + if (line.startsWith("@@ ")) { + if (inHunk && (oldLeft !== 0 || newLeft !== 0)) return false; + const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?:.*)$/.exec(line); + if (header === null) return false; + oldLeft = Number(header[1] ?? 1); + newLeft = Number(header[2] ?? 1); + inHunk = true; + hunks += 1; + continue; + } + if (!inHunk) continue; + if (line === "" && index === lines.length - 1) continue; + if (line.startsWith("\\ No newline at end of file")) continue; + if (line.startsWith(" ")) { + oldLeft -= 1; + newLeft -= 1; + } else if (line.startsWith("-")) { + oldLeft -= 1; + } else if (line.startsWith("+")) { + newLeft -= 1; + } else { + return false; + } + if (oldLeft < 0 || newLeft < 0) return false; + } + return hunks > 0 && oldLeft === 0 && newLeft === 0; +} + +/** + * A mark names the exact file section returned with the diff, not an independently fetched + * latest-head value. A host response that omits hunks, truncates, or names a file ambiguously + * grants no digest and therefore no viewed-file control for that file. + */ +export function fileDigestsFromPatch( + patch: string, + truncated: boolean, + scope: PatchDigestScope, + excludedPaths: ReadonlySet = new Set(), +): ReadonlyArray<{ readonly path: string; readonly digest: string }> { + if (truncated) return []; + const headers = [...patch.matchAll(/^diff --git /gm)].map((match) => match.index); + const found = new Map(); + const ambiguous = new Set(); + for (let index = 0; index < headers.length; index += 1) { + const section = patch.slice(headers[index], headers[index + 1] ?? patch.length); + if (!hasCompleteHunks(section) || /^(?:Binary files |GIT binary patch)/m.test(section)) + continue; + const newToken = /^\+\+\+ ([^\r\n]+)/m.exec(section)?.[1]?.split("\t", 1)[0]; + const oldToken = /^--- ([^\r\n]+)/m.exec(section)?.[1]?.split("\t", 1)[0]; + const token = newToken === "/dev/null" ? oldToken : newToken; + if (token === undefined) continue; + const unquoted = unquoteGitPatchPath(token); + if (!/^[ab]\//.test(unquoted)) continue; + const path = unquoted.slice(2); + if (path.length === 0 || ambiguous.has(path) || excludedPaths.has(path)) continue; + if (found.has(path)) { + found.delete(path); + ambiguous.add(path); + continue; + } + const digest = NodeCrypto.createHash("sha256") + .update( + JSON.stringify([scope.provider, scope.host, scope.remote, scope.number, path, section]), + ) + .digest("hex"); + found.set(path, digest); + } + return [...found].map(([path, digest]) => ({ path, digest })); +} diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 1f9aa33ac7..4681b669e0 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -18,6 +18,7 @@ import { import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; +import { fileDigestsFromPatch } from "./pullRequestPatchDigests.ts"; /** * How long the head's version of a file is believed, and how long a held answer stands while the @@ -34,6 +35,8 @@ export const FILE_REVISIONS_CACHE_CAPACITY = 64; * otherwise. */ export const MAX_FILE_REVISION_PATHS = 1_000; +const MAX_HOST_MARK_PREFLIGHT_SLICES = 8; +const DISPLAY_DIGEST = /^[0-9a-f]{64}$/; interface FileRevisionsDependencies { readonly runFork: (effect: Effect.Effect) => unknown; @@ -58,6 +61,8 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { } const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); + const inFlightRevisionReads = new Map(); + let revisionReadSequence = 0; /** * Carries the reference's epoch, so whatever moved the head strands what was held (or in @@ -66,13 +71,16 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { * remote's own spelling. */ const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) => - [ + JSON.stringify([ refEpoch({ ...ref, host: project.host, repository: project.repository }), fileRevisionsEpoch(), ref.projectId, + project.api.kind, + project.host, + project.remote, project.repository.trim().toLowerCase(), ref.number, - ].join(" "); + ]); /** * `paths` are what was asked about, and are held as answered for whether the host had a version @@ -162,6 +170,8 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { // the request is built rather than as the effect is run. const fetch = Effect.suspend(() => { const key = fileRevisionsKey(project, ref); + const readId = ++revisionReadSequence; + inFlightRevisionReads.set(key, readId); return read({ cwd: project.project.workspaceRoot, repository: project.repository, @@ -170,16 +180,25 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { paths, }).pipe( Effect.mapError(toPullRequestError(operation)), - Effect.flatMap((answer) => recordFileRevisions(key, paths, answer)), + Effect.flatMap((answer) => + inFlightRevisionReads.get(key) === readId + ? recordFileRevisions(key, paths, answer) + : Effect.succeed(answer.revisions), + ), + Effect.ensuring( + Effect.sync(() => { + if (inFlightRevisionReads.get(key) === readId) inFlightRevisionReads.delete(key); + }), + ), ); }); + if (freshness === "fresh") return fetch; return Effect.flatMap(Clock.currentTimeMillis, (now) => { const key = fileRevisionsKey(project, ref); const held = heldFileRevisionsFor(key, 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(key)) 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. @@ -205,7 +224,7 @@ export interface Dependencies extends FileRevisionsDependencies { readonly requiredViewerOf: ( project: SupportedProject, operation: string, - ) => Effect.Effect; + ) => Effect.Effect; } // A plain factory rather than a `Context.Service` (against the preference in @@ -215,17 +234,44 @@ export interface Dependencies extends FileRevisionsDependencies { export const make = (dependencies: Dependencies) => { const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; const { fileRevisionsOf } = makeFileRevisions(dependencies); + const confirmViewer = (project: SupportedProject, expected: string, operation: string) => + requiredViewerOf(project, operation).pipe( + Effect.flatMap((current) => + current === expected + ? Effect.void + : Effect.fail( + new PullRequestOperationError({ + operation, + detail: + "The signed-in account changed while viewed files were being read or updated. Refresh and try again.", + }), + ), + ), + ); + const invalidDisplayedFiles = (files: PullRequestSetFilesViewedInput["files"]) => + files.some((file) => file.viewed && !DISPLAY_DIGEST.test(file.digest ?? "")); + + const requireDisplayedFiles = (files: PullRequestSetFilesViewedInput["files"]) => + invalidDisplayedFiles(files) + ? Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: + "Refresh the diff before marking files viewed; its file revision is unavailable.", + }), + ) + : Effect.void; /** * Which change request's marks, and whose. Provider and host lead the key because the same * repository can exist on more than one install; the reader is part of it because a host's own - * record is per-account. A host that names no reader is one reader, not none. + * record is per-account. An unverified reader cannot access persisted marks. */ - const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({ + const filesViewedScope = (project: SupportedProject, number: number, viewer: string) => ({ provider: project.api.kind, host: project.host, repository: project.remote, number, - viewer: viewer ?? "", + viewer, }); const toFilesViewedStoreError = (operation: string) => (cause: unknown) => @@ -267,15 +313,22 @@ export const make = (dependencies: Dependencies) => { }).pipe(Effect.as(null)), ), ); + yield* confirmViewer(project, viewer, "filesViewed"); return { files: marks.map((mark) => { // A mark stamped with no baseline holds until the reader presses it again. - if (mark.revision === null) return { path: mark.path, state: "viewed" as const }; + if (mark.revision === null) + return { + path: mark.path, + ...(mark.displayDigest === null ? {} : { digest: mark.displayDigest }), + state: "viewed" as const, + }; const revision = revisions?.get(mark.path); // A deleted file is answered as the empty revision, matching its stamp, so it stays // cleared; a path the host had no answer for (`undefined`) also holds as cleared. return { path: mark.path, + ...(mark.displayDigest === null ? {} : { digest: mark.displayDigest }), state: revision === undefined || revision === mark.revision ? ("viewed" as const) @@ -288,9 +341,9 @@ export const make = (dependencies: Dependencies) => { }); /** - * One environment-backed write at a time per change request. A tick's host round trip is - * slower than an untick's, so unordered presses could finish out of order and leave a stale - * tick standing over a later untick. + * One write at a time per change request, whether marks live on the host or in this + * environment. A tick's host round trip can be slower than an untick's, so unordered presses + * could finish out of order and leave an older tick standing over a later untick. */ const filesViewedGates = new Map< string, @@ -306,7 +359,13 @@ export const make = (dependencies: Dependencies) => { // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert // lets two presses each make a gate of their own and neither wait on the other. Effect.suspend(() => { - const key = `${project.project.id} ${project.remote} ${number}`; + const key = JSON.stringify([ + project.project.id, + project.api.kind, + project.host, + project.remote, + number, + ]); const held = filesViewedGates.get(key); const entry = held ?? { gate: Semaphore.makeUnsafe(1), pending: 0 }; if (held === undefined) filesViewedGates.set(key, entry); @@ -330,6 +389,7 @@ export const make = (dependencies: Dependencies) => { input: PullRequestSetFilesViewedInput, ): Effect.Effect => Effect.gen(function* () { + yield* requireDisplayedFiles(input.files); const viewer = yield* requiredViewerOf(project, "setFilesViewed"); // 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. @@ -348,6 +408,7 @@ export const make = (dependencies: Dependencies) => { ), ); const viewedAt = DateTime.formatIso(yield* DateTime.now); + yield* confirmViewer(project, viewer, "setFilesViewed"); yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), @@ -357,11 +418,13 @@ export const make = (dependencies: Dependencies) => { files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, + displayDigest: file.digest ?? null, viewed: file.viewed, })), viewedAt, }) .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed"))); + yield* confirmViewer(project, viewer, "setFilesViewed"); }); const filesViewed = (input: PullRequestRef) => @@ -395,13 +458,63 @@ export const make = (dependencies: Dependencies) => { Effect.flatMap((project): Effect.Effect => { const write = project.api.setFilesViewed; 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"))); + return inFilesViewedOrder( + project, + input.number, + Effect.gen(function* () { + yield* requireDisplayedFiles(input.files); + const marked = input.files.filter((file) => file.viewed); + const cursors = [...new Set(marked.map((file) => file.cursor ?? null))]; + if (cursors.length > MAX_HOST_MARK_PREFLIGHT_SLICES) { + return yield* new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "Refresh the diff and mark fewer files at once.", + }); + } + for (const cursor of cursors) { + const slice = yield* project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(cursor === null ? {} : { cursor }), + }) + .pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + const current = new Map( + fileDigestsFromPatch( + slice.patch, + slice.truncated, + { + provider: project.api.kind, + host: project.host, + remote: project.remote, + number: input.number, + }, + new Set(slice.omittedFileStats?.map((file) => file.path) ?? []), + ).map(({ path, digest }) => [path, digest]), + ); + if ( + marked.some( + (file) => + (file.cursor ?? null) === cursor && current.get(file.path) !== file.digest, + ) + ) { + return yield* new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "The displayed diff changed. Refresh it before marking files viewed.", + }); + } + } + yield* 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 inFilesViewedOrder( diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 09973476cf..d6cc653c14 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -117,6 +117,7 @@ interface DiffSlice { readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats: ReadonlyArray; + readonly fileDigests: ReadonlyArray<{ readonly path: string; readonly digest: string }>; } /** @@ -303,6 +304,7 @@ function PullRequestCodeTab({ truncated: data.truncated, nextCursor: data.nextCursor, omittedFileStats: data.omittedFileStats ?? [], + fileDigests: data.fileDigests ?? [], }; const index = slices.findIndex((slice) => slice.cursor === cursor); if (index === -1) { @@ -414,6 +416,20 @@ function PullRequestCodeTab({ [parsedSlices], ); const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]); + const fileEvidence = useMemo(() => { + const found = new Map(); + const ambiguous = new Set(); + for (const slice of loadedSlices) { + for (const { path, digest } of slice.fileDigests) { + if (ambiguous.has(path)) continue; + if (found.has(path)) { + found.delete(path); + ambiguous.add(path); + } else found.set(path, { digest, cursor: slice.cursor }); + } + } + return found; + }, [loadedSlices]); // 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 is kept against the change request rather // than the scope it was made in, so clearing a file here clears it everywhere. @@ -421,11 +437,13 @@ function PullRequestCodeTab({ const filesViewed = usePullRequestFilesViewed({ environmentId, reference, - enabled: viewedFilesStore !== undefined, + enabled: viewedFilesStore !== undefined && selectedCommitOid === null, paths: filePaths, + evidence: fileEvidence, }); const { setViewed, + isTrackable: isFileTrackable, refresh: refreshFilesViewed, enabled: filesViewedEnabled, isViewed: isFileViewed, @@ -656,12 +674,13 @@ function PullRequestCodeTab({ // than derived from what has been ticked, so folding everything ticks nothing off. const setFileViewed = useCallback( (fileKey: string, path: string, viewed: boolean) => { + if (!isFileTrackable(path)) return; setViewed(path, viewed); setToggledFiles((current) => toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), ); }, - [effectiveFoldOverride, setViewed], + [effectiveFoldOverride, isFileTrackable, setViewed], ); const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey); @@ -837,7 +856,7 @@ function PullRequestCodeTab({ /> ); const viewedFiles = filesViewedRef.current; - if (!viewedFiles.enabled) return stat; + if (!viewedFiles.enabled || !viewedFiles.isTrackable(path)) return stat; const viewed = viewedFiles.isViewed(path); const stale = viewedFiles.isStale(path); return ( diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts index 409013a1b2..6c3c1f5919 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts @@ -94,7 +94,17 @@ export function revertFileViewedOverlay( /** 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 })); + overlay: ReadonlyMap< + string, + boolean | { readonly viewed: boolean; readonly digest?: string; readonly cursor?: string } + >, +): ReadonlyArray<{ + readonly path: string; + readonly viewed: boolean; + readonly digest?: string; + readonly cursor?: string; +}> { + return [...overlay].map(([path, value]) => + typeof value === "boolean" ? { path, viewed: value } : { path, ...value }, + ); } diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx index 78314b7ca7..995fee8e46 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx @@ -42,6 +42,7 @@ const reference: PullRequestRef = { number: 42, }; const paths = ["a.ts"]; +let evidence = new Map([["a.ts", { digest: "a".repeat(64), cursor: null }]]); /** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */ function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { @@ -55,7 +56,13 @@ function Probe(_props: { readonly view: PullRequestFilesViewedView }) { } function Surface() { - const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths }); + const view = usePullRequestFilesViewed({ + environmentId, + reference, + enabled: true, + paths, + evidence, + }); return ; } @@ -79,6 +86,7 @@ beforeEach(async () => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); host.data = answer("unviewed"); + evidence = new Map([["a.ts", { digest: "a".repeat(64), cursor: null }]]); host.refresh.mockReset(); toastAdd.mockReset(); setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); @@ -104,7 +112,7 @@ describe("a mark whose file was pushed to before the read that followed it", () await act(async () => vi.advanceTimersByTimeAsync(500)); expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ environmentId, - input: { ...reference, files: [{ path: "a.ts", viewed: true }] }, + input: { ...reference, files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }] }, }); expect(host.refresh).toHaveBeenCalled(); @@ -159,3 +167,31 @@ describe("a mark the host has not answered for yet", () => { expect(view().isStale("a.ts")).toBe(false); }); }); + +it("never shows the old mark or pending press as viewed over a newly displayed file", async () => { + host.data = { + files: [{ path: "a.ts", state: "viewed", digest: "a".repeat(64) }], + truncated: false, + } satisfies PullRequestFilesViewedResult; + await act(async () => + renderer!.update( + + + , + ), + ); + expect(view().isViewed("a.ts")).toBe(true); + + view().setViewed("a.ts", true); + evidence = new Map([["a.ts", { digest: "b".repeat(64), cursor: null }]]); + await act(async () => + renderer!.update( + + + , + ), + ); + expect(view().isViewed("a.ts")).toBe(false); + expect(view().isStale("a.ts")).toBe(true); + expect(view().viewedCount).toBe(0); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index c2ee3f4c0b..ed430ed4a0 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -32,6 +32,7 @@ export interface PullRequestFilesViewedView { /** Whether anything remembers this at all, which is what hides the whole control. */ readonly enabled: boolean; readonly isViewed: (path: string) => boolean; + readonly isTrackable: (path: string) => boolean; /** This file has been pushed to since it was cleared. */ readonly isStale: (path: string) => boolean; readonly setViewed: (path: string, viewed: boolean) => void; @@ -61,8 +62,12 @@ export function usePullRequestFilesViewed(options: { readonly enabled: boolean; /** The paths on screen, which is what the counter counts. */ readonly paths: ReadonlyArray; + readonly evidence: ReadonlyMap< + string, + { readonly digest: string; readonly cursor: string | null } + >; }): PullRequestFilesViewedView { - const { environmentId, reference, enabled, paths } = options; + const { environmentId, reference, enabled, paths, evidence } = options; const query = useEnvironmentQuery( enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null, ); @@ -70,10 +75,33 @@ export function usePullRequestFilesViewed(options: { // `query.data` holds the last answer through a failure, so the boxes stay where the host last // put them rather than emptying under the reader; the error travels with them, because ticks // that stopped being refreshed look exactly like ticks that are current. - const states = useMemo(() => toFileViewedStates(query.data), [query.data]); + const states = useMemo( + () => + toFileViewedStates( + query.data === null + ? null + : { + ...query.data, + files: query.data.files.map((file) => + file.digest !== undefined && file.digest !== evidence.get(file.path)?.digest + ? { ...file, state: "dismissed" as const } + : file, + ), + }, + ), + [query.data, evidence], + ); const truncated = query.data?.truncated === true; const error = query.error; const [overlay, setOverlay] = useState(NO_OVERLAY); + const [overlayDigests, setOverlayDigests] = useState>(new Map()); + const visibleOverlay = useMemo( + () => + new Map( + [...overlay].filter(([path]) => overlayDigests.get(path) === evidence.get(path)?.digest), + ), + [overlay, overlayDigests, evidence], + ); const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, { reportFailure: false, }); @@ -82,7 +110,9 @@ export function usePullRequestFilesViewed(options: { // request that is. A path pressed again while its request is out belongs to the later request // from then 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 queued = useRef< + Map + >(new Map()); const sentBy = useRef>(new Map()); const requests = useRef(0); const flushTimer = useRef | null>(null); @@ -90,7 +120,13 @@ export function usePullRequestFilesViewed(options: { // 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 scopeKey = JSON.stringify([ + environmentId, + reference.projectId, + reference.host ?? null, + reference.repository, + reference.number, + ]); const scope = useRef(scopeKey); // The host's answer as it stood when a completed write was acknowledged, per path. The first @@ -166,6 +202,7 @@ export function usePullRequestFilesViewed(options: { queued.current = new Map(); sentBy.current = new Map(); answeredFrom.current = new Map(); + setOverlayDigests(new Map()); setOverlay(NO_OVERLAY); }; }, [scopeKey]); @@ -174,24 +211,36 @@ export function usePullRequestFilesViewed(options: { 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); - if (flushTimer.current !== null) clearTimeout(flushTimer.current); - flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); - }, []); + const setViewed = useCallback( + (path: string, viewed: boolean) => { + const shown = evidence.get(path); + if (shown === undefined) return; + setOverlayDigests((current) => new Map(current).set(path, shown.digest)); + setOverlay((current) => new Map(current).set(path, viewed)); + queued.current.set(path, { + viewed, + digest: shown.digest, + ...(shown.cursor === null ? {} : { cursor: shown.cursor }), + }); + if (flushTimer.current !== null) clearTimeout(flushTimer.current); + flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); + }, + [evidence], + ); + + const isTrackable = useCallback((path: string) => evidence.has(path), [evidence]); const isViewed = useCallback( - (path: string) => isFileViewed(path, states, overlay), - [overlay, states], + (path: string) => isFileViewed(path, states, visibleOverlay), + [visibleOverlay, states], ); const isStale = useCallback( - (path: string) => !overlay.has(path) && isStaleViewedState(states?.get(path)), - [overlay, states], + (path: string) => !visibleOverlay.has(path) && isStaleViewedState(states?.get(path)), + [visibleOverlay, states], ); const viewedCount = useMemo( - () => countViewedFiles(paths, states, overlay), - [overlay, paths, states], + () => countViewedFiles(paths, states, visibleOverlay), + [visibleOverlay, paths, states], ); // One identity per change of what it says: the viewer keys every file it draws off this. @@ -199,6 +248,7 @@ export function usePullRequestFilesViewed(options: { () => ({ enabled, isViewed, + isTrackable, isStale, setViewed, viewedCount, @@ -206,6 +256,16 @@ export function usePullRequestFilesViewed(options: { error, refresh: refreshFromHost, }), - [enabled, error, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount], + [ + enabled, + error, + isStale, + isViewed, + isTrackable, + refreshFromHost, + setViewed, + truncated, + viewedCount, + ], ); } diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 2e1df8c968..916f6fd78b 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -277,7 +277,13 @@ export function createPullRequestEnvironmentAtoms( concurrency: { mode: "serial", key: ({ environmentId, input }) => - JSON.stringify([environmentId, input.projectId, input.repository, input.number]), + JSON.stringify([ + environmentId, + input.projectId, + input.host ?? null, + input.repository, + input.number, + ]), }, }), runAction: createEnvironmentRpcCommand(runtime, { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 77e143c58b..651c01e1f8 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -918,6 +918,10 @@ export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type; export const PullRequestDiffResult = Schema.Struct({ patch: Schema.String, + /** Exact complete file sections in this slice, bound to this host and pull request. */ + fileDigests: Schema.optional( + Schema.Array(Schema.Struct({ path: Schema.String, digest: Schema.String })), + ), /** * Something inside this slice could not be shown — a binary file, or a hunk the host declined * to inline. Not the same as there being more slices, which `nextCursor` answers. @@ -972,6 +976,8 @@ export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; export const PullRequestFileViewed = Schema.Struct({ path: FilePath, state: PullRequestFileViewedState, + /** Displayed file-section digest for environment-kept marks, if known. */ + digest: Schema.optional(Schema.String), }); export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; @@ -1005,6 +1011,10 @@ export const PullRequestSetFilesViewedInput = Schema.Struct({ Schema.Struct({ path: FilePath, viewed: Schema.Boolean, + /** Required for a viewed press; absent for an untick. */ + digest: Schema.optional(Schema.String), + /** Slice shown when pressed, for a host-managed preflight comparison. */ + cursor: Schema.optional(Schema.String), }), ).check(Schema.isMaxLength(MAX_FILES_VIEWED_PRESSES)), }); From c91090539880abb091332ac348586c055e3be93c Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:20:56 -0600 Subject: [PATCH 03/16] Fence viewed-file actions by displayed account and mark digest --- .../src/persistence/PullRequestFilesViewed.ts | 5 +- .../pullRequest/PullRequestService.test.ts | 245 ++++++++++++++++-- .../src/pullRequest/PullRequestService.ts | 2 +- .../src/pullRequest/pullRequestViewedFiles.ts | 45 +++- .../pullRequest/PullRequestCodeTab.tsx | 24 +- .../usePullRequestFilesViewed.test.tsx | 80 +++++- .../pullRequest/usePullRequestFilesViewed.ts | 55 +++- packages/contracts/src/pullRequest.test.ts | 12 + packages/contracts/src/pullRequest.ts | 14 +- 9 files changed, 417 insertions(+), 65 deletions(-) diff --git a/apps/server/src/persistence/PullRequestFilesViewed.ts b/apps/server/src/persistence/PullRequestFilesViewed.ts index 9f884a8629..f83a292cc9 100644 --- a/apps/server/src/persistence/PullRequestFilesViewed.ts +++ b/apps/server/src/persistence/PullRequestFilesViewed.ts @@ -16,8 +16,8 @@ import { /** * Which change request, on which host, for which reader. The host is part of it because the same * `group/project` exists on gitlab.com and on a self-managed instance, and the reader because - * 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. + * signing in as somebody else must not inherit their ticks. Callers verify the current account + * before choosing this scope. */ export const PullRequestFilesViewedScope = Schema.Struct({ provider: SourceControlProviderKind, @@ -176,6 +176,7 @@ const make = Effect.gen(function* () { AND number = ${input.number} AND viewer = ${input.viewer} AND path = ${file.path} + AND (display_digest IS NULL OR display_digest = ${file.displayDigest}) `, { discard: true }, ), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 7491370d25..3a5f448dea 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -5032,6 +5032,13 @@ it.effect("keeps the diff cached across a file being ticked off", () => let diffReads = 0; let viewedReads = 0; let state: "viewed" | "dismissed" = "viewed"; + const patch = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1 +1 @@ +-before ++after +`; const service = yield* makeService({ projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), @@ -5051,7 +5058,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => }, getDiff: () => { diffReads += 1; - return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); + return Effect.succeed({ patch, truncated: false, nextCursor: null }); }, getFilesViewed: () => { viewedReads += 1; @@ -5066,14 +5073,19 @@ it.effect("keeps the diff cached across a file being ticked off", () => }); const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - yield* service.diff(reference); + const digest = (yield* service.diff(reference)).fileDigests?.[0]?.digest; + assert.isString(digest); yield* service.filesViewed(reference); - yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); + yield* service.setFilesViewed({ + expectedViewer: "bilal", + ...reference, + files: [{ path: "src/a.ts", viewed: false, digest }], + }); 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(diffReads, 2); assert.strictEqual(viewedReads, 2); state = "dismissed"; @@ -5082,7 +5094,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ { path: "src/a.ts", state: "dismissed" }, ]); - assert.strictEqual(diffReads, 1); + assert.strictEqual(diffReads, 2); assert.strictEqual(viewedReads, 3); }), ); @@ -5132,11 +5144,19 @@ it.effect("serializes host-backed presses on the same pull request", () => const digest = (yield* service.diff(reference)).fileDigests?.[0]?.digest; assert.isString(digest); const first = yield* service - .setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: true, digest }] }) + .setFilesViewed({ + expectedViewer: "bilal", + ...reference, + files: [{ path: "src/a.ts", viewed: true, digest }], + }) .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(firstStarted); const second = yield* service - .setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }) + .setFilesViewed({ + expectedViewer: "bilal", + ...reference, + files: [{ path: "src/a.ts", viewed: false, digest }], + }) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; assert.deepStrictEqual(calls, [true]); @@ -5186,10 +5206,71 @@ it.effect("rejects a host mark when the displayed file section changed before th const shown = (yield* service.diff(reference)).fileDigests?.[0]?.digest; assert.isString(shown); patch = patch.replace("+first", "+second"); + for (const viewed of [true, false]) { + const error = yield* service + .setFilesViewed({ + expectedViewer: "bilal", + ...reference, + files: [{ path: "src/a.ts", viewed, digest: shown }], + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + } + assert.strictEqual(writes, 0); + }), +); + +it.effect("rejects a host mark if CLI auth changes during diff preflight", () => + Effect.gen(function* () { + let account = "alice"; + let diffReads = 0; + let writes = 0; + const patch = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1 +1 @@ +-before ++after +`; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "project", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + viewedFiles: "host", + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewer: () => Effect.sync(() => account), + getDiff: () => + Effect.sync(() => { + diffReads += 1; + if (diffReads === 2) account = "bob"; + return { patch, truncated: false, nextCursor: null }; + }), + setFilesViewed: () => + Effect.sync(() => { + writes += 1; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const digest = (yield* service.diff(reference)).fileDigests?.[0]?.digest; + assert.isString(digest); const error = yield* service .setFilesViewed({ ...reference, - files: [{ path: "src/a.ts", viewed: true, digest: shown }], + expectedViewer: "alice", + files: [{ path: "src/a.ts", viewed: true, digest }], }) .pipe(Effect.flip); assert.strictEqual(error._tag, "PullRequestOperationError"); @@ -5220,12 +5301,14 @@ it.effect( ], }); yield* service.setFilesViewed({ + expectedViewer: account, ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); account = "bob"; assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, []); yield* service.setFilesViewed({ + expectedViewer: account, ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5268,6 +5351,7 @@ it.effect("rejects an environment mark when the account changes during revision }); const press = yield* service .setFilesViewed({ + expectedViewer: account, ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }) @@ -5283,6 +5367,40 @@ it.effect("rejects an environment mark when the account changes during revision }), ); +it.effect("rejects a press from the previous account's displayed marks", () => + Effect.gen(function* () { + let account = "alice"; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + { + ...environmentViewedProvider(new Map(), []), + getViewer: () => Effect.sync(() => account), + }, + ], + }); + assert.strictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).viewer, "alice"); + account = "bob"; + const error = yield* service + .setFilesViewed({ + ...GITLAB_REFERENCE, + expectedViewer: "alice", + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, []); + }), +); + const environmentViewedProvider = ( revisions: Map, asked: Array>, @@ -5342,6 +5460,31 @@ const GITLAB_REFERENCE = { number: 1, }; +it.effect("a stale untick cannot delete a newer mark on the same file", () => + Effect.gen(function* () { + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); + const newerDigest = "b".repeat(64); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + expectedViewer: "bilal", + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + expectedViewer: "bilal", + files: [{ path: "src/a.ts", viewed: true, digest: newerDigest }], + }); + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + expectedViewer: "bilal", + files: [{ path: "src/a.ts", viewed: false, digest: TEST_DISPLAY_DIGEST }], + }); + assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ + { path: "src/a.ts", state: "viewed", digest: newerDigest }, + ]); + }), +); + it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () => Effect.gen(function* () { let alpha = "b485fe5"; @@ -5410,6 +5553,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba }; const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"]; yield* service.setFilesViewed({ + expectedViewer: "reviewer", ...reference, files: paths.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); @@ -5426,8 +5570,9 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba new Map(paths.map((path) => [path, path === "alpha.ts" ? "dismissed" : "viewed"])), ); yield* service.setFilesViewed({ + expectedViewer: "reviewer", ...reference, - files: [{ path: "beta.ts", viewed: false }], + files: [{ path: "beta.ts", viewed: false, digest: TEST_DISPLAY_DIGEST }], }); assert.isFalse( (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"), @@ -5436,6 +5581,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba truncated = true; yield* service.invalidate({ reference }); yield* service.setFilesViewed({ + expectedViewer: "reviewer", ...reference, files: [{ path: "beta.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5484,10 +5630,14 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi }; const second = { ...first, repository: "reviewer/second" }; const files = [{ path: "same.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }]; - yield* service.setFilesViewed({ ...first, files }); + yield* service.setFilesViewed({ expectedViewer: "bilal", ...first, files }); assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); - yield* service.setFilesViewed({ ...second, files }); - yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); + yield* service.setFilesViewed({ expectedViewer: "bilal", ...second, files }); + yield* service.setFilesViewed({ + expectedViewer: "bilal", + ...first, + files: [{ path: "same.ts", viewed: false, digest: TEST_DISPLAY_DIGEST }], + }); assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ { path: "same.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, ]); @@ -5538,10 +5688,11 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () // Nothing marked is nothing to ask the host about. const empty = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual(empty, { files: [], truncated: false }); + assert.deepStrictEqual(empty, { viewer: "bilal", files: [], truncated: false }); assert.deepStrictEqual(asked, []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [ { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, @@ -5598,12 +5749,14 @@ it.effect("reads a fresh version for each tick even when a whole-change answer i }); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); // The second path came back with the first answer. A press still rereads current host state // instead of stamping a held version after the head may have moved. yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5622,6 +5775,7 @@ it.effect("reads a fresh version for each tick even when a whole-change answer i // than stamping a mark with a version the head may have moved off. yield* TestClock.adjust("2 minutes"); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5635,6 +5789,7 @@ it.effect("reads the marks without asking the host what the head has every time" const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5656,6 +5811,7 @@ it.effect("answers the marks from what it last heard while it asks the host agai const service = yield* environmentViewedService(revisions, asked); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5692,11 +5848,13 @@ it.effect("asks the host about a file it has not been asked about before", () => ); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("20 seconds"); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5725,6 +5883,7 @@ it.effect("does not let a press about one file keep another file's version alive const service = yield* environmentViewedService(revisions, asked); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5733,6 +5892,7 @@ it.effect("does not let a press about one file keep another file's version alive // the whole scope as heard from would put the first file's version back inside the window it // had almost aged out of, and a reader working down a long diff renews it press after press. yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5765,6 +5925,7 @@ it.effect("reports a file pushed to since it was cleared as changed", () => const service = yield* environmentViewedService(revisions, []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [ { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, @@ -5792,12 +5953,14 @@ it.effect("clears a mark again when the file is put back", () => const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: false }], + files: [{ path: "src/a.ts", viewed: false, digest: TEST_DISPLAY_DIGEST }], }); const marked = yield* service.filesViewed(GITLAB_REFERENCE); @@ -5812,6 +5975,7 @@ it.effect("keeps a deleted file cleared, which the head has no version of at all const service = yield* environmentViewedService(new Map(), []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/gone.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5839,6 +6003,7 @@ it.effect("leaves a mark alone when the host could not say what the head has of ); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [ { path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }, @@ -5870,6 +6035,7 @@ it.effect("keeps a file cleared that the press could not learn a version for", ( const service = yield* environmentViewedService(revisions, [], unreadable); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/past-the-cut.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5890,6 +6056,7 @@ it.effect("keeps the version it last heard when a later read of the head stops s const service = yield* environmentViewedService(revisions, asked, unreadable); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5919,6 +6086,7 @@ it.effect("re-asks what the head has of a marked file after a whole-workspace re const service = yield* environmentViewedService(revisions, []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5939,6 +6107,7 @@ it.effect("forgets what the head had of a marked file once a mutation moves the const service = yield* environmentViewedService(revisions, []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -5992,6 +6161,7 @@ it.effect("still reports its own marks when the host will not say what the head }); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6047,6 +6217,7 @@ it.effect("finishes two presses on one file in the order they were made", () => const tick = service .setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }) @@ -6055,8 +6226,9 @@ it.effect("finishes two presses on one file in the order they were made", () => yield* TestClock.adjust("1 second"); const untick = service .setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: false }], + files: [{ path: "src/a.ts", viewed: false, digest: TEST_DISPLAY_DIGEST }], }) .pipe(Effect.runFork); yield* TestClock.adjust("1 second"); @@ -6124,6 +6296,7 @@ it.effect("keeps the marks of two Azure repositories of the same name apart", () const service = yield* azureViewedService(AZURE_PAIR); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...AZURE_PLATFORM, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6140,6 +6313,7 @@ it.effect("keeps environment marks apart from another change request's", () => const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6163,7 +6337,11 @@ it.effect("bounds the paths one change request's held revisions carry", () => digest: TEST_DISPLAY_DIGEST, })); const press = (prefix: string, count: number) => - service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); + service.setFilesViewed({ + expectedViewer: "bilal", + ...GITLAB_REFERENCE, + files: batch(prefix, count), + }); yield* press("a", MAX_FILE_REVISION_PATHS / 2); yield* press("b", MAX_FILE_REVISION_PATHS / 2); @@ -6214,6 +6392,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c const ticked = ["src/f0000.ts", "src/f0500.ts"]; yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: ticked.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); @@ -6241,6 +6420,7 @@ it.effect("bounds held revision scopes while fresh presses continue to read the const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); const press = (number: number) => service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, number, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], @@ -6275,21 +6455,24 @@ const environmentViewedServiceWithViewer = ( providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], }); -it.effect("keeps one reader's marks on a host that names nobody", () => +it.effect("refuses viewed marks when the host cannot name the current reader", () => Effect.gen(function* () { const service = yield* environmentViewedServiceWithViewer( new Map([["src/a.ts", "blob-a"]]), () => Effect.succeed(""), ); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], - }); + const write = yield* service + .setFilesViewed({ + expectedViewer: "", + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], + }) + .pipe(Effect.flip); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed", digest: TEST_DISPLAY_DIGEST }, - ]); + const read = yield* service.filesViewed(GITLAB_REFERENCE).pipe(Effect.flip); + assert.strictEqual(write._tag, "PullRequestOperationError"); + assert.strictEqual(read._tag, "PullRequestOperationError"); }), ); @@ -6322,13 +6505,13 @@ it.effect("rechecks the viewed mark account separately from a listing lookup", ( }); // What a cold page load does: read the listing and the reader's own marks at the same time. - // The mark lookup verifies its account at the end as well, so a switch during the read cannot - // return another account's marks. Listing retains its own display viewer lookup. + // The mark lookup verifies its account at both ends, even for an empty result, so a switch + // during the read cannot return another account's marks. Listing has its own lookup. yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { concurrency: 2, }); - assert.strictEqual(viewerLookups, 2); + assert.strictEqual(viewerLookups, 3); }), ); @@ -6345,6 +6528,7 @@ it.effect("carries a bounded number of its own marks and says it held more", () ); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: paths.map((path) => ({ path, viewed: true, digest: TEST_DISPLAY_DIGEST })), }); @@ -6396,11 +6580,13 @@ it.effect("records a press while the host is backing off", () => }); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); yield* TestClock.adjust("11 minutes"); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6461,6 +6647,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin }); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6477,6 +6664,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin // The press is bounded by what the reader does, and its rows are keyed by who they are, so // it is asked rather than refused. yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6560,6 +6748,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( ); yield* service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }); @@ -6573,6 +6762,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); const write = yield* Effect.flip( service.setFilesViewed({ + expectedViewer: "bilal", ...GITLAB_REFERENCE, files: [{ path: "src/b.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }), @@ -6611,6 +6801,7 @@ it.effect("refuses to track viewed files on a host that does not", () => const read = yield* Effect.flip(service.filesViewed(reference)); const write = yield* Effect.flip( service.setFilesViewed({ + expectedViewer: "bilal", ...reference, files: [{ path: "a.ts", viewed: true, digest: TEST_DISPLAY_DIGEST }], }), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f9afeb2bef..d5535c089b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1742,7 +1742,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.mapError(toPullRequestError(operation)), Effect.flatMap((viewer) => - viewer === null + viewer === null || viewer.trim().length === 0 ? Effect.fail( new PullRequestOperationError({ operation, diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 4681b669e0..76352b2c20 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -249,7 +249,17 @@ export const make = (dependencies: Dependencies) => { ), ); const invalidDisplayedFiles = (files: PullRequestSetFilesViewedInput["files"]) => - files.some((file) => file.viewed && !DISPLAY_DIGEST.test(file.digest ?? "")); + files.some((file) => !DISPLAY_DIGEST.test(file.digest ?? "")); + + const requireExpectedViewer = (input: PullRequestSetFilesViewedInput, viewer: string) => + input.expectedViewer === viewer + ? Effect.void + : Effect.fail( + new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "The signed-in account changed. Refresh viewed files before updating them.", + }), + ); const requireDisplayedFiles = (files: PullRequestSetFilesViewedInput["files"]) => invalidDisplayedFiles(files) @@ -297,7 +307,10 @@ export const make = (dependencies: Dependencies) => { .list(filesViewedScope(project, ref.number, viewer)) .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed"))); const marks = held.files; - if (marks.length === 0) return { files: [], truncated: held.truncated }; + if (marks.length === 0) { + yield* confirmViewer(project, viewer, "filesViewed"); + return { viewer, files: [], truncated: held.truncated }; + } // A host that won't say what its head has costs the marks their staleness (`fileRevisionsOf` // returns null), rather than costing the reader every tick they've made. const revisions = yield* fileRevisionsOf( @@ -315,6 +328,7 @@ export const make = (dependencies: Dependencies) => { ); yield* confirmViewer(project, viewer, "filesViewed"); return { + viewer, files: marks.map((mark) => { // A mark stamped with no baseline holds until the reader presses it again. if (mark.revision === null) @@ -391,6 +405,7 @@ export const make = (dependencies: Dependencies) => { Effect.gen(function* () { yield* requireDisplayedFiles(input.files); const viewer = yield* requiredViewerOf(project, "setFilesViewed"); + yield* requireExpectedViewer(input, viewer); // 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); @@ -432,12 +447,17 @@ export const make = (dependencies: Dependencies) => { Effect.flatMap((project): Effect.Effect => { const read = project.api.getFilesViewed; 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"))); + return Effect.gen(function* () { + const viewer = yield* requiredViewerOf(project, "filesViewed"); + const result = yield* read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); + yield* confirmViewer(project, viewer, "filesViewed"); + return { ...result, viewer }; + }); } if (project.api.capabilities.viewedFiles === "environment") { return environmentFilesViewed(project, input); @@ -463,8 +483,9 @@ export const make = (dependencies: Dependencies) => { input.number, Effect.gen(function* () { yield* requireDisplayedFiles(input.files); - const marked = input.files.filter((file) => file.viewed); - const cursors = [...new Set(marked.map((file) => file.cursor ?? null))]; + const viewer = yield* requiredViewerOf(project, "setFilesViewed"); + yield* requireExpectedViewer(input, viewer); + const cursors = [...new Set(input.files.map((file) => file.cursor ?? null))]; if (cursors.length > MAX_HOST_MARK_PREFLIGHT_SLICES) { return yield* new PullRequestOperationError({ operation: "setFilesViewed", @@ -495,7 +516,7 @@ export const make = (dependencies: Dependencies) => { ).map(({ path, digest }) => [path, digest]), ); if ( - marked.some( + input.files.some( (file) => (file.cursor ?? null) === cursor && current.get(file.path) !== file.digest, ) @@ -506,6 +527,7 @@ export const make = (dependencies: Dependencies) => { }); } } + yield* confirmViewer(project, viewer, "setFilesViewed"); yield* write({ cwd: project.project.workspaceRoot, repository: project.repository, @@ -513,6 +535,7 @@ export const make = (dependencies: Dependencies) => { number: input.number, files: input.files, }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + yield* confirmViewer(project, viewer, "setFilesViewed"); }), ); } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d6cc653c14..93afa9a9f2 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -225,6 +225,9 @@ function PullRequestCodeTab({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [toggledFiles, setToggledFiles] = useState>(() => new Set()); + const foldBeforeViewedPress = useRef( + new Map(), + ); // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather // than a choice. The rest arrive ten at a time, on request. const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); @@ -434,12 +437,26 @@ function PullRequestCodeTab({ // 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 restoreRejectedViewedFolds = useCallback((paths: ReadonlyArray) => { + setToggledFiles((current) => { + const next = new Set(current); + for (const path of paths) { + const before = foldBeforeViewedPress.current.get(path); + if (before === undefined) continue; + if (before.toggled) next.add(before.fileKey); + else next.delete(before.fileKey); + foldBeforeViewedPress.current.delete(path); + } + return next; + }); + }, []); const filesViewed = usePullRequestFilesViewed({ environmentId, reference, enabled: viewedFilesStore !== undefined && selectedCommitOid === null, paths: filePaths, evidence: fileEvidence, + onWriteRejected: restoreRejectedViewedFolds, }); const { setViewed, @@ -676,9 +693,10 @@ function PullRequestCodeTab({ (fileKey: string, path: string, viewed: boolean) => { if (!isFileTrackable(path)) return; setViewed(path, viewed); - setToggledFiles((current) => - toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), - ); + setToggledFiles((current) => { + foldBeforeViewedPress.current.set(path, { fileKey, toggled: current.has(fileKey) }); + return toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current); + }); }, [effectiveFoldOverride, isFileTrackable, setViewed], ); diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx index 995fee8e46..edee742242 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx @@ -5,12 +5,13 @@ import { type PullRequestRef, } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; import { act, StrictMode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ - host: { data: null as unknown, refresh: vi.fn() }, + host: { data: null as unknown, error: null as string | null, refresh: vi.fn() }, setFilesViewed: vi.fn(), toastAdd: vi.fn(), })); @@ -21,7 +22,7 @@ vi.mock("~/state/pullRequests", () => ({ vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: host.data, - error: null, + error: host.error, isPending: false, isSuccess: true, refresh: host.refresh, @@ -43,10 +44,11 @@ const reference: PullRequestRef = { }; const paths = ["a.ts"]; let evidence = new Map([["a.ts", { digest: "a".repeat(64), cursor: null }]]); +const onWriteRejected = vi.fn(); /** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */ function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { - return { files: [{ path: "a.ts", state }], truncated: false }; + return { viewer: "bilal", files: [{ path: "a.ts", state }], truncated: false }; } let renderer: ReactTestRenderer | null = null; @@ -62,6 +64,7 @@ function Surface() { enabled: true, paths, evidence, + onWriteRejected, }); return ; } @@ -86,8 +89,10 @@ beforeEach(async () => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); host.data = answer("unviewed"); + host.error = null; evidence = new Map([["a.ts", { digest: "a".repeat(64), cursor: null }]]); host.refresh.mockReset(); + onWriteRejected.mockReset(); toastAdd.mockReset(); setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); act(() => { @@ -112,7 +117,11 @@ describe("a mark whose file was pushed to before the read that followed it", () await act(async () => vi.advanceTimersByTimeAsync(500)); expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ environmentId, - input: { ...reference, files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }] }, + input: { + ...reference, + expectedViewer: "bilal", + files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }], + }, }); expect(host.refresh).toHaveBeenCalled(); @@ -170,6 +179,7 @@ describe("a mark the host has not answered for yet", () => { it("never shows the old mark or pending press as viewed over a newly displayed file", async () => { host.data = { + viewer: "bilal", files: [{ path: "a.ts", state: "viewed", digest: "a".repeat(64) }], truncated: false, } satisfies PullRequestFilesViewedResult; @@ -195,3 +205,65 @@ it("never shows the old mark or pending press as viewed over a newly displayed f expect(view().isStale("a.ts")).toBe(true); expect(view().viewedCount).toBe(0); }); + +it("reverses the coupled file fold when a mark is rejected", async () => { + setFilesViewed.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("denied")))); + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(view().isViewed("a.ts")).toBe(false); + expect(onWriteRejected).toHaveBeenCalledExactlyOnceWith(["a.ts"]); +}); + +it("sends the account that owned a queued press even after the next account's answer arrives", async () => { + view().setViewed("a.ts", true); + host.data = { + viewer: "new-account", + files: [], + truncated: false, + } satisfies PullRequestFilesViewedResult; + await act(async () => + renderer!.update( + + + , + ), + ); + expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { + ...reference, + expectedViewer: "bilal", + files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }], + }, + }); + expect(view().isViewed("a.ts")).toBe(false); +}); + +it("hides cached marks and disables writes after an account read fails", async () => { + host.data = { + viewer: "bilal", + files: [{ path: "a.ts", state: "viewed" }], + truncated: false, + } satisfies PullRequestFilesViewedResult; + await act(async () => + renderer!.update( + + + , + ), + ); + expect(view().isViewed("a.ts")).toBe(true); + host.error = "account lookup failed"; + await act(async () => + renderer!.update( + + + , + ), + ); + expect(view().enabled).toBe(false); + expect(view().isViewed("a.ts")).toBe(false); + view().setViewed("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index ed430ed4a0..b544f08017 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -62,23 +62,26 @@ export function usePullRequestFilesViewed(options: { readonly enabled: boolean; /** The paths on screen, which is what the counter counts. */ readonly paths: ReadonlyArray; + /** Restore UI gestures that were coupled to a press when the host rejects that press. */ + readonly onWriteRejected?: (paths: ReadonlyArray) => void; readonly evidence: ReadonlyMap< string, { readonly digest: string; readonly cursor: string | null } >; }): PullRequestFilesViewedView { - const { environmentId, reference, enabled, paths, evidence } = options; + const { environmentId, reference, enabled, paths, evidence, onWriteRejected } = options; const query = useEnvironmentQuery( enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null, ); const refresh = query.refresh; - // `query.data` holds the last answer through a failure, so the boxes stay where the host last - // put them rather than emptying under the reader; the error travels with them, because ticks - // that stopped being refreshed look exactly like ticks that are current. + const viewer = query.error === null ? query.data?.viewer : undefined; + const accountReady = enabled && viewer !== undefined; + // A failed refresh cannot authenticate the cached answer's account. Hide those marks until a + // successful read rather than leaving another account's state on screen indefinitely. const states = useMemo( () => toFileViewedStates( - query.data === null + query.data === null || query.error !== null ? null : { ...query.data, @@ -89,9 +92,9 @@ export function usePullRequestFilesViewed(options: { ), }, ), - [query.data, evidence], + [query.data, query.error, evidence], ); - const truncated = query.data?.truncated === true; + const truncated = query.error === null && query.data?.truncated === true; const error = query.error; const [overlay, setOverlay] = useState(NO_OVERLAY); const [overlayDigests, setOverlayDigests] = useState>(new Map()); @@ -126,6 +129,7 @@ export function usePullRequestFilesViewed(options: { reference.host ?? null, reference.repository, reference.number, + viewer ?? null, ]); const scope = useRef(scopeKey); @@ -155,7 +159,10 @@ export function usePullRequestFilesViewed(options: { const sentFrom = scope.current; const request = ++requests.current; for (const file of batch) sentBy.current.set(file.path, request); - void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => { + void setFilesViewed({ + environmentId, + input: { ...reference, expectedViewer: viewer, files: batch }, + }).then((result) => { const mine = batch .map((file) => file.path) .filter((path) => sentBy.current.get(path) === request); @@ -168,6 +175,7 @@ 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)); + if (owned.size > 0) onWriteRejected?.([...owned]); // Silent when nothing was still this request's to answer for, so nothing on screen went // back, and when the connection went away mid-flight, which the reader is already being // told about and which the host never refused. @@ -181,7 +189,7 @@ export function usePullRequestFilesViewed(options: { for (const path of mine) answeredFrom.current.set(path, statesRef.current); refresh(); }); - }, [environmentId, reference, refresh, setFilesViewed]); + }, [environmentId, reference, refresh, setFilesViewed, viewer, onWriteRejected]); // 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. @@ -210,9 +218,27 @@ export function usePullRequestFilesViewed(options: { const refreshRef = useRef(refresh); refreshRef.current = refresh; const refreshFromHost = useCallback(() => refreshRef.current(), []); + // Provider CLI sign-in can change outside this tab. Refresh on focus and while the tab is + // visible so an account switch replaces the old reader's cached marks without a page reload. + useEffect(() => { + if (!enabled) return; + if (typeof window === "undefined" || typeof document === "undefined") return; + const refreshIfVisible = () => { + if (document.visibilityState === "visible") refreshRef.current(); + }; + const timer = setInterval(refreshIfVisible, 15_000); + window.addEventListener("focus", refreshIfVisible); + document.addEventListener("visibilitychange", refreshIfVisible); + return () => { + clearInterval(timer); + window.removeEventListener("focus", refreshIfVisible); + document.removeEventListener("visibilitychange", refreshIfVisible); + }; + }, [enabled]); const setViewed = useCallback( (path: string, viewed: boolean) => { + if (!accountReady) return; const shown = evidence.get(path); if (shown === undefined) return; setOverlayDigests((current) => new Map(current).set(path, shown.digest)); @@ -225,10 +251,13 @@ export function usePullRequestFilesViewed(options: { if (flushTimer.current !== null) clearTimeout(flushTimer.current); flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); }, - [evidence], + [accountReady, evidence], ); - const isTrackable = useCallback((path: string) => evidence.has(path), [evidence]); + const isTrackable = useCallback( + (path: string) => accountReady && evidence.has(path), + [accountReady, evidence], + ); const isViewed = useCallback( (path: string) => isFileViewed(path, states, visibleOverlay), @@ -246,7 +275,7 @@ export function usePullRequestFilesViewed(options: { // One identity per change of what it says: the viewer keys every file it draws off this. return useMemo( () => ({ - enabled, + enabled: accountReady, isViewed, isTrackable, isStale, @@ -257,7 +286,7 @@ export function usePullRequestFilesViewed(options: { refresh: refreshFromHost, }), [ - enabled, + accountReady, error, isStale, isViewed, diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 3945f083ac..7ca24cd2ec 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -328,6 +328,18 @@ describe("naming the reader as the author to narrow by", () => { }); describe("naming the file a tick belongs to", () => { + it("accepts only canonical bounded displayed-file digests", () => { + const input = (digest: string) => ({ + projectId: "p1", + repository: "group/project", + number: 7, + expectedViewer: "bilal", + files: [{ path: "src/a.ts", viewed: true, digest }], + }); + expect(() => decodeSetFilesViewed(input("a".repeat(64)))).not.toThrow(); + expect(() => decodeSetFilesViewed(input("A".repeat(64)))).toThrow(); + expect(() => decodeSetFilesViewed(input("a".repeat(65)))).toThrow(); + }); // A space on either end of a name is part of the name as far as git is concerned. The patch on // screen and the environment's record of what was cleared are both keyed by it, so a path // tidied in transit ticks a file that does not exist and leaves the one on screen unticked. diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 651c01e1f8..03fd4ff6aa 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -916,11 +916,13 @@ export const PullRequestOmittedFileStat = Schema.Struct({ }); export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type; +const FileSectionDigest = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)); + export const PullRequestDiffResult = Schema.Struct({ patch: Schema.String, /** Exact complete file sections in this slice, bound to this host and pull request. */ fileDigests: Schema.optional( - Schema.Array(Schema.Struct({ path: Schema.String, digest: Schema.String })), + Schema.Array(Schema.Struct({ path: Schema.String, digest: FileSectionDigest })), ), /** * Something inside this slice could not be shown — a binary file, or a hunk the host declined @@ -977,7 +979,7 @@ export const PullRequestFileViewed = Schema.Struct({ path: FilePath, state: PullRequestFileViewedState, /** Displayed file-section digest for environment-kept marks, if known. */ - digest: Schema.optional(Schema.String), + digest: Schema.optional(FileSectionDigest), }); export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; @@ -987,6 +989,8 @@ export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; * press, so one read would have to be wrong for the other to be right. */ export const PullRequestFilesViewedResult = Schema.Struct({ + /** Account that owned this answer; the client must echo it when applying a press. */ + viewer: Schema.optional(TrimmedNonEmptyString), /** 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, so the count is short and says so. */ @@ -1007,12 +1011,14 @@ const MAX_FILES_VIEWED_PRESSES = 500; */ export const PullRequestSetFilesViewedInput = Schema.Struct({ ...PullRequestRef.fields, + /** Account whose displayed marks and diff motivated this press. */ + expectedViewer: Schema.optional(TrimmedNonEmptyString), files: Schema.Array( Schema.Struct({ path: FilePath, viewed: Schema.Boolean, - /** Required for a viewed press; absent for an untick. */ - digest: Schema.optional(Schema.String), + /** Displayed file section for either a tick or untick. */ + digest: Schema.optional(FileSectionDigest), /** Slice shown when pressed, for a host-managed preflight comparison. */ cursor: Schema.optional(Schema.String), }), From 2b0a7b2d201e554be3c6620900a5a03bb8ca64b5 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:28:44 -0600 Subject: [PATCH 04/16] fix: pin GitHub viewed-file actions to one credential --- apps/server/src/git/GitManager.test.ts | 1 + .../src/pullRequest/GitHubPullRequestCli.ts | 5 + .../pullRequest/GitHubPullRequestProvider.ts | 2 + .../src/pullRequest/PullRequestProvider.ts | 7 + .../pullRequest/PullRequestService.test.ts | 7 + .../src/pullRequest/PullRequestService.ts | 5 + .../src/pullRequest/pullRequestViewedFiles.ts | 160 +++++++++++------- .../src/sourceControl/GitHubCli.test.ts | 46 +++++ apps/server/src/sourceControl/GitHubCli.ts | 65 ++++++- 9 files changed, 231 insertions(+), 67 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 0d489a1c04..98cd28f377 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -508,6 +508,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { + snapshotCredential: () => Effect.die("Unexpected credential snapshot in GitManager test"), execute, listOpenPullRequests: (input) => execute({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 3e815c74ef..1302f7094c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -436,6 +436,10 @@ export interface GitHubPullRequestFilesViewed { export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCli, { + readonly snapshotViewedFilesCredential: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; readonly getViewerLogin: (input: { readonly cwd: string; }) => Effect.Effect; @@ -1539,6 +1543,7 @@ export const make = Effect.gen(function* () { ); return GitHubPullRequestCli.of({ + snapshotViewedFilesCredential: github.snapshotCredential, getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( Effect.flatMap((result) => { diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 44f91e8b43..99a99a1e24 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -222,6 +222,8 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: "github", capabilities: CAPABILITIES, + snapshotViewedFilesCredential: (input) => + cli.snapshotViewedFilesCredential(input).pipe(Effect.mapError(fail("snapshotViewedFilesCredential"))), getViewer: (input) => cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index ccad5e3ceb..f32f2b9f96 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type { GitHubCredentialSnapshot } from "../sourceControl/GitHubCli.ts"; import type { PullRequestStackMembership, PullRequestAction, @@ -306,6 +307,12 @@ export interface PullRequestProviderApi { readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; + /** A host-owned mark uses the same authenticated account for every CLI call in one request. */ + readonly snapshotViewedFilesCredential?: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; + /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: (input: { readonly cwd: string; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3a5f448dea..ef555db820 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { @@ -148,6 +149,12 @@ function fakeProvider( ): PullRequestProviderApi { return { kind, + snapshotViewedFilesCredential: ({ host }) => + Effect.succeed({ + host, + token: Redacted.make("fixture-token"), + credentialFingerprint: "fixture-credential", + }), capabilities: { diff: true, comment: true, diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index d5535c089b..ddf2887992 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -507,6 +507,11 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, + ...(api.snapshotViewedFilesCredential === undefined + ? {} + : { + snapshotViewedFilesCredential: api.snapshotViewedFilesCredential, + }), getViewer: wrap("getViewer", api.getViewer, allowPausedViewer), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 76352b2c20..010cd149be 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; import { fileDigestsFromPatch } from "./pullRequestPatchDigests.ts"; @@ -248,6 +249,27 @@ export const make = (dependencies: Dependencies) => { ), ), ); + const withPinnedHostCredential = ( + project: SupportedProject, + operation: string, + action: Effect.Effect, + ): Effect.Effect => { + const snapshot = project.api.snapshotViewedFilesCredential; + if (snapshot === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation, + detail: "This host cannot bind viewed-file actions to one signed-in account.", + }), + ); + } + return snapshot({ cwd: project.project.workspaceRoot, host: project.host }).pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((credential) => + action.pipe(Effect.provideService(GitHubCli.PinnedGitHubCredential, credential)), + ), + ); + }; const invalidDisplayedFiles = (files: PullRequestSetFilesViewedInput["files"]) => files.some((file) => !DISPLAY_DIGEST.test(file.digest ?? "")); @@ -447,17 +469,21 @@ export const make = (dependencies: Dependencies) => { Effect.flatMap((project): Effect.Effect => { const read = project.api.getFilesViewed; if (project.api.capabilities.viewedFiles === "host" && read) { - return Effect.gen(function* () { - const viewer = yield* requiredViewerOf(project, "filesViewed"); - const result = yield* read({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); - yield* confirmViewer(project, viewer, "filesViewed"); - return { ...result, viewer }; - }); + return withPinnedHostCredential( + project, + "filesViewed", + Effect.gen(function* () { + const viewer = yield* requiredViewerOf(project, "filesViewed"); + const result = yield* read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("filesViewed"))); + yield* confirmViewer(project, viewer, "filesViewed"); + return { ...result, viewer }; + }), + ); } if (project.api.capabilities.viewedFiles === "environment") { return environmentFilesViewed(project, input); @@ -478,65 +504,69 @@ export const make = (dependencies: Dependencies) => { Effect.flatMap((project): Effect.Effect => { const write = project.api.setFilesViewed; if (project.api.capabilities.viewedFiles === "host" && write) { - return inFilesViewedOrder( + return withPinnedHostCredential( project, - input.number, - Effect.gen(function* () { - yield* requireDisplayedFiles(input.files); - const viewer = yield* requiredViewerOf(project, "setFilesViewed"); - yield* requireExpectedViewer(input, viewer); - const cursors = [...new Set(input.files.map((file) => file.cursor ?? null))]; - if (cursors.length > MAX_HOST_MARK_PREFLIGHT_SLICES) { - return yield* new PullRequestOperationError({ - operation: "setFilesViewed", - detail: "Refresh the diff and mark fewer files at once.", - }); - } - for (const cursor of cursors) { - const slice = yield* project.api - .getDiff({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - ...(cursor === null ? {} : { cursor }), - }) - .pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); - const current = new Map( - fileDigestsFromPatch( - slice.patch, - slice.truncated, - { - provider: project.api.kind, - host: project.host, - remote: project.remote, - number: input.number, - }, - new Set(slice.omittedFileStats?.map((file) => file.path) ?? []), - ).map(({ path, digest }) => [path, digest]), - ); - if ( - input.files.some( - (file) => - (file.cursor ?? null) === cursor && current.get(file.path) !== file.digest, - ) - ) { + "setFilesViewed", + inFilesViewedOrder( + project, + input.number, + Effect.gen(function* () { + yield* requireDisplayedFiles(input.files); + const viewer = yield* requiredViewerOf(project, "setFilesViewed"); + yield* requireExpectedViewer(input, viewer); + const cursors = [...new Set(input.files.map((file) => file.cursor ?? null))]; + if (cursors.length > MAX_HOST_MARK_PREFLIGHT_SLICES) { return yield* new PullRequestOperationError({ operation: "setFilesViewed", - detail: "The displayed diff changed. Refresh it before marking files viewed.", + detail: "Refresh the diff and mark fewer files at once.", }); } - } - yield* confirmViewer(project, viewer, "setFilesViewed"); - yield* write({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - files: input.files, - }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); - yield* confirmViewer(project, viewer, "setFilesViewed"); - }), + for (const cursor of cursors) { + const slice = yield* project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(cursor === null ? {} : { cursor }), + }) + .pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + const current = new Map( + fileDigestsFromPatch( + slice.patch, + slice.truncated, + { + provider: project.api.kind, + host: project.host, + remote: project.remote, + number: input.number, + }, + new Set(slice.omittedFileStats?.map((file) => file.path) ?? []), + ).map(({ path, digest }) => [path, digest]), + ); + if ( + input.files.some( + (file) => + (file.cursor ?? null) === cursor && current.get(file.path) !== file.digest, + ) + ) { + return yield* new PullRequestOperationError({ + operation: "setFilesViewed", + detail: "The displayed diff changed. Refresh it before marking files viewed.", + }); + } + } + yield* confirmViewer(project, viewer, "setFilesViewed"); + yield* write({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + files: input.files, + }).pipe(Effect.mapError(toPullRequestError("setFilesViewed"))); + yield* confirmViewer(project, viewer, "setFilesViewed"); + }), + ), ); } if (project.api.capabilities.viewedFiles === "environment") { diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 0ad99cd28b..4bc7afc548 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -42,6 +42,52 @@ const layer = GitHubCli.layer.pipe( ), ); +it.effect("snapshots one credential with tracing suppressed and never exposes token failures", () => + Effect.gen(function* () { + mockRun.mockImplementationOnce(() => Effect.succeed(processOutput("snapshot-secret\n"))); + const gh = yield* GitHubCli.GitHubCli; + const credential = yield* gh.snapshotCredential({ cwd: "/repo", host: "github.com" }); + expect(mockRun.mock.calls[0]?.[0]).toMatchObject({ + args: ["auth", "token", "--hostname", "github.com"], + env: { GH_DEBUG: "", GIT_TRACE: "0", GIT_CURL_VERBOSE: "0" }, + }); + expect(JSON.stringify(credential)).not.toContain("snapshot-secret"); + mockRun.mockImplementationOnce((input) => + Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")), + ); + const result = yield* gh + .execute({ + cwd: "/repo", + args: ["api", "user", "--hostname", "github.com"], + env: { GH_DEBUG: "api", GH_TOKEN: "ambient-secret" }, + }) + .pipe(Effect.provideService(GitHubCli.PinnedGitHubCredential, credential)); + expect(result.stdout).toBe("snapshot-secret"); + expect(mockRun.mock.calls[1]?.[0].env).toMatchObject({ + GH_DEBUG: "", + GH_TOKEN: "snapshot-secret", + GITHUB_TOKEN: "snapshot-secret", + }); + mockRun.mockImplementationOnce(() => + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.snapshotCredential", + command: "gh auth token", + cwd: "/repo", + exitCode: 1, + failureKind: "authentication", + detail: "snapshot-secret in stderr", + }), + ), + ); + const failure = yield* gh + .snapshotCredential({ cwd: "/repo", host: "github.com" }) + .pipe(Effect.flip); + expect(JSON.stringify(failure)).not.toContain("snapshot-secret"); + expect(yield* encodeGitHubCliError(failure)).not.toContain("snapshot-secret"); + }).pipe(Effect.provide(layer)), +); + afterEach(() => { mockRun.mockReset(); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index c525740efe..2227bfd9de 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -10,6 +10,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as NodeCrypto from "node:crypto"; import { TrimmedNonEmptyString, @@ -29,11 +30,15 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ -export const PinnedGitHubCredential = Context.Reference<{ +export interface GitHubCredentialSnapshot { readonly host: string; readonly token: Redacted.Redacted; readonly credentialFingerprint: string; -} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +} +export const PinnedGitHubCredential = Context.Reference( + "t3/sourceControl/PinnedGitHubCredential", + { defaultValue: () => null }, +); export const AllowGitHubReserve = Context.Reference( "t3/sourceControl/AllowGitHubReserve", @@ -281,6 +286,10 @@ export interface GitHubRepositoryCloneUrls { export class GitHubCli extends Context.Service< GitHubCli, { + readonly snapshotCredential: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; readonly execute: (input: { readonly cwd: string; readonly args: ReadonlyArray; @@ -400,6 +409,53 @@ export const make = Effect.gen(function* () { const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const snapshotCredential: GitHubCli["Service"]["snapshotCredential"] = Effect.fn( + "GitHubCli.snapshotCredential", + )(function* (input) { + // Read the token once, then pass it only in child environment variables. Neither stderr nor + // the token is retained in a failure cause, including when gh exits with tracing enabled. + const output = yield* process + .run({ + operation: "GitHubCli.snapshotCredential", + command: "gh", + args: ["auth", "token", "--hostname", input.host], + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxOutputBytes: 4096, + env: { + GH_DEBUG: "", + GH_PROMPT_DISABLED: "1", + GIT_TRACE: "0", + GIT_TRACE_PACKET: "0", + GIT_TRACE_SETUP: "0", + GIT_CURL_VERBOSE: "0", + }, + }) + .pipe( + Effect.mapError( + () => + new GitHubCliAuthenticationError({ + command: "gh", + cwd: input.cwd, + cause: new Error("Could not snapshot GitHub credentials."), + }), + ), + ); + const token = output.stdout.trim(); + if (output.stdoutTruncated || token.length === 0 || token.includes("\n")) { + return yield* new GitHubCliAuthenticationError({ + command: "gh", + cwd: input.cwd, + cause: new Error("Could not snapshot GitHub credentials."), + }); + } + return { + host: input.host.toLowerCase(), + token: Redacted.make(token), + credentialFingerprint: NodeCrypto.createHash("sha256").update(token).digest("hex"), + }; + }); + const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( function* (input) { const credential = yield* PinnedGitHubCredential; @@ -422,6 +478,10 @@ export const make = Effect.gen(function* () { GH_ENTERPRISE_TOKEN: token, GITHUB_ENTERPRISE_TOKEN: token, GH_DEBUG: "", + GIT_TRACE: "0", + GIT_TRACE_PACKET: "0", + GIT_TRACE_SETUP: "0", + GIT_CURL_VERBOSE: "0", }; return yield* process .run({ @@ -520,6 +580,7 @@ export const make = Effect.gen(function* () { ); return GitHubCli.of({ + snapshotCredential, execute, listOpenPullRequests: (input) => execute({ From 6dbc35399771ecf62a4e541dfdd404e6d143e83c Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:31:57 -0600 Subject: [PATCH 05/16] fix: verify pinned viewer on explicit host and surface read failures --- .../src/pullRequest/GitHubPullRequestCli.ts | 30 +++++++++++----- .../pullRequest/GitHubPullRequestProvider.ts | 4 ++- .../src/sourceControl/GitHubCli.test.ts | 20 +++++++++++ .../pullRequest/PullRequestCodeTab.tsx | 36 +++++++++++-------- 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 1302f7094c..f03adb69d8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -442,6 +442,7 @@ export class GitHubPullRequestCli extends Context.Service< }) => Effect.Effect; readonly getViewerLogin: (input: { readonly cwd: string; + readonly host?: string; }) => Effect.Effect; readonly listPullRequests: (input: { @@ -1545,14 +1546,27 @@ export const make = Effect.gen(function* () { return GitHubPullRequestCli.of({ snapshotViewedFilesCredential: github.snapshotCredential, getViewerLogin: (input) => - github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( - Effect.flatMap((result) => { - const login = result.stdout.trim(); - return login.length > 0 - ? Effect.succeed(login) - : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd })); - }), - ), + github + .execute({ + cwd: input.cwd, + args: [ + "api", + "user", + ...(input.host === undefined ? [] : ["--hostname", input.host]), + "--jq", + ".login", + ], + }) + .pipe( + Effect.flatMap((result) => { + const login = result.stdout.trim(); + return login.length > 0 + ? Effect.succeed(login) + : Effect.fail( + new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }), + ); + }), + ), listPullRequests: (input) => { const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 99a99a1e24..36f74716c7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -226,7 +226,9 @@ export const make = Effect.gen(function* () { cli.snapshotViewedFilesCredential(input).pipe(Effect.mapError(fail("snapshotViewedFilesCredential"))), getViewer: (input) => - cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + cli.getViewerLogin({ cwd: input.cwd, ...(input.host === undefined ? {} : { host: input.host }) }).pipe( + Effect.mapError(fail("getViewer")), + ), listChangeRequests: (input) => cli diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 4bc7afc548..7f0af808bf 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -13,6 +13,7 @@ import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubPullRequestCli from "../pullRequest/GitHubPullRequestCli.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; @@ -88,6 +89,25 @@ it.effect("snapshots one credential with tracing suppressed and never exposes to }).pipe(Effect.provide(layer)), ); +it.effect("resolves the viewed-files viewer through the pinned host-aware GitHub driver", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("the-reader\n"))); + const driver = yield* GitHubPullRequestCli.make; + const viewer = yield* driver.getViewerLogin({ cwd: "/repo", host: "github.example.test" }).pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.example.test", + token: Redacted.make("pinned-secret"), + credentialFingerprint: "fingerprint", + }), + ); + expect(viewer).toBe("the-reader"); + expect(mockRun.mock.calls[0]?.[0]).toMatchObject({ + args: ["api", "user", "--hostname", "github.example.test", "--jq", ".login"], + env: { GH_TOKEN: "pinned-secret", GH_DEBUG: "" }, + }); + }).pipe(Effect.provide(layer)), +); + afterEach(() => { mockRun.mockReset(); }); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 93afa9a9f2..e02d54c4b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1231,6 +1231,28 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {filesViewed.error !== null ? ( + + + } + > + + + + Viewed-file controls are unavailable until the signed-in account can be checked.{" "} + {filesViewed.error} Select this icon to retry. + + + ) : null} {filesViewed.enabled && files.length > 0 ? ( {/* Named on a host that keeps no record of its own, so the reader is told whose @@ -1257,20 +1279,6 @@ function PullRequestCodeTab({ ) : null} - {filesViewed.error !== null ? ( - - }> - - - - The boxes below are whatever was last read, and empty if nothing has been read - yet. {filesViewed.error} - - - ) : null} {filesViewed.truncated ? ( }> From 8a5842938eb63e140a4388bd511312e16ac2ed5d Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:34:24 -0600 Subject: [PATCH 06/16] docs: clarify viewed-file read failure state --- .../src/components/pullRequest/usePullRequestFilesViewed.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index b544f08017..6671c251e0 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -41,9 +41,8 @@ export interface PullRequestFilesViewedView { /** The host had more files than the read covered, so the count above may be short. */ readonly truncated: boolean; /** - * Why the marks could not be read, when they could not. The boxes fall back to the last answer - * there was, or to empty when there has not been one, and neither of those says so on its own: - * a reader who sees every box unticked has no way to tell a fresh review from a failed read. + * Why the marks could not be read. Until the account is verified again, controls and cached + * marks stay hidden; the failure remains visible so a reader can retry. */ readonly error: string | null; /** Re-ask the host, for the page's refresh button, which goes around the host's cache. */ From 00c767603cd295996189398a693e4fcfd67c0dd6 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:40:49 -0600 Subject: [PATCH 07/16] style(server): format viewed-files provider paths --- .../AzureDevOpsPullRequestProvider.ts | 62 +++-- .../BitbucketPullRequestProvider.ts | 20 +- .../pullRequest/GitHubPullRequestProvider.ts | 213 +++++++++--------- .../pullRequest/GitLabPullRequestProvider.ts | 70 +++--- .../src/pullRequest/gitHubPullRequestJson.ts | 46 ++-- 5 files changed, 196 insertions(+), 215 deletions(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index cc84ab6c0a..b194e50047 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -261,15 +261,13 @@ export const make = Effect.gen(function* () { ], { concurrency: 2 }, ).pipe( - Effect.map( - ([oldItem, newItem]): 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, - }), - ), + Effect.map(([oldItem, newItem]): 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, + })), ); /** @@ -332,22 +330,20 @@ export const make = Effect.gen(function* () { getChangeRequestSummary: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequestSummary")), - Effect.map( - (pullRequest): ProviderChangeRequestSummary => ({ - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.url, - author: pullRequest.author, - headBranch: pullRequest.headBranch, - baseBranch: pullRequest.baseBranch, - state: pullRequest.state, - isDraft: pullRequest.isDraft, - mergeability: pullRequest.mergeability, - closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, - mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, - updatedAt: pullRequest.updatedAt, - }), - ), + Effect.map((pullRequest): ProviderChangeRequestSummary => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + updatedAt: pullRequest.updatedAt, + })), ), getChangeRequest: (input) => @@ -406,15 +402,13 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => ({ comments: [], truncated: true })), ) ).pipe( - Effect.map( - (conversation): ProviderChangeRequestActivity => ({ - comments: conversation.comments, - commentCount: conversation.comments.length, - commentsTruncated: conversation.truncated, - reviewThreads: [], - commits: [], - }), - ), + Effect.map((conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + })), ), ), ), diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index c26c0ad342..634519d467 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -234,17 +234,15 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ - comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: comments.comments.length + pullRequest.reviews.length, - commentsTruncated: comments.truncated, - reviewThreads: comments.threads, - commits, - }), - ), + Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + })), ); }, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 36f74716c7..686aaf8f8f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -155,14 +155,12 @@ function withWorkflowApprovals( } const approvalChecks = runs .filter((run) => !representedRunIds.has(run.id)) - .map( - (run): PullRequestCheck => ({ - name: run.name, - status: "action-required", - description: "A maintainer must approve this workflow before it can run.", - url: run.url, - }), - ); + .map((run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + })); return [ ...checks, ...approvalChecks, @@ -223,12 +221,17 @@ export const make = Effect.gen(function* () { kind: "github", capabilities: CAPABILITIES, snapshotViewedFilesCredential: (input) => - cli.snapshotViewedFilesCredential(input).pipe(Effect.mapError(fail("snapshotViewedFilesCredential"))), + cli + .snapshotViewedFilesCredential(input) + .pipe(Effect.mapError(fail("snapshotViewedFilesCredential"))), getViewer: (input) => - cli.getViewerLogin({ cwd: input.cwd, ...(input.host === undefined ? {} : { host: input.host }) }).pipe( - Effect.mapError(fail("getViewer")), - ), + cli + .getViewerLogin({ + cwd: input.cwd, + ...(input.host === undefined ? {} : { host: input.host }), + }) + .pipe(Effect.mapError(fail("getViewer"))), listChangeRequests: (input) => cli @@ -391,39 +394,35 @@ export const make = Effect.gen(function* () { { concurrency: 3 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...detail.pullRequest, - author: withAvatar(detail.pullRequest.author, new Map(), input.host), - checks: withWorkflowApprovals( - detail.pullRequest.checks, - detail.workflowApprovals.runs, - detail.workflowApprovals.unavailable, - ), - ...(detail.workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), - reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...viewerAccess, - canUpdateBranch: detail.comparison?.viewerCanUpdate === true, - }), - baseComparison: - detail.comparison === null || detail.comparison.behindBy === null - ? "unknown" - : detail.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(detail.comparison?.behindBy == null - ? {} - : { behindBy: detail.comparison.behindBy }), + Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + author: withAvatar(detail.pullRequest.author, new Map(), input.host), + checks: withWorkflowApprovals( + detail.pullRequest.checks, + detail.workflowApprovals.runs, + detail.workflowApprovals.unavailable, + ), + ...(detail.workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, }), - ), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), + })), ), getChangeRequestActivity: (input) => @@ -456,74 +455,72 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ - author: withAvatar( - pullRequest.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - reviewers: reviewThreads.reviewers, - reactions: reviewThreads.reactions, - commits: (reviewThreads.commits.length > 0 - ? reviewThreads.commits - : pullRequest.commits - ).map((commit) => ({ - ...commit, - ...reviewThreads.commitStats.get(commit.oid), - authors: commit.authors?.map( - (author) => - withAvatar( - author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ) ?? author, - ), - })), - comments: [...pullRequest.comments, ...reviewThreads.comments] - .map((comment) => ({ - ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. - body: - comment.kind === "review" && - comment.reviewState?.toUpperCase() === "DISMISSED" && - rendersEmpty(comment.body) - ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) - : comment.body, - author: withAvatar( - comment.author, - reviewThreads.avatarsByLogin, - input.host, - reviewThreads.botLogins, - ), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. - reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], - })) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. - commentCount: pullRequest.comments.length + reviewThreads.commentCount, - commentsTruncated: reviewThreads.truncated, - reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - author: withAvatar( - comment.author, + Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar( + pullRequest.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => + withAvatar( + author, reviewThreads.avatarsByLogin, input.host, reviewThreads.botLogins, - ), - })), + ) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar( + comment.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar( + comment.author, + reviewThreads.avatarsByLogin, + input.host, + reviewThreads.botLogins, + ), })), - }), - ), + })), + })), ), getReviewThreadComments: (input) => diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 17788910bc..c1482d601b 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -152,24 +152,22 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ - ...mergeRequest, - mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. - baseComparison: - mergeRequest.divergedCommits === undefined - ? "unknown" - : mergeRequest.divergedCommits > 0 - ? "behind" - : "up-to-date", - ...(mergeRequest.divergedCommits === undefined - ? {} - : { behindBy: mergeRequest.divergedCommits }), - }), - ), + Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + })), ), getChangeRequestActivity: (input) => @@ -194,28 +192,26 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ + Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ ...comment, reactions: awards.reactionsByNoteId.get(comment.id) ?? [], })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), - commits, - }), - ), + })), + commits, + })), ), // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 1180d8040d..854d7cccbd 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1404,18 +1404,16 @@ function toComments(raw: { readonly comments?: ReadonlyArray> | undefined; readonly reviews?: ReadonlyArray> | undefined; }): ReadonlyArray { - const issueComments = (raw.comments ?? []).map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "issue-comment", - author: toActor(comment.author), - body: comment.body ?? "", - createdAt: comment.createdAt, - url: trimmed(comment.url), - path: null, - reviewState: null, - }), - ); + const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + })); // A review with no body is kept only when its state is the event itself — an approval, a // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the // container for line comments, and those comments are read from the review threads, so @@ -1864,19 +1862,17 @@ export function reviewThreadConversation( threads: ReadonlyArray, ): ReadonlyArray { return threads.flatMap((thread) => - thread.comments.map( - (comment): PullRequestComment => ({ - id: comment.id, - kind: "review-comment", - author: comment.author, - body: comment.body, - createdAt: comment.createdAt, - url: comment.url, - path: thread.path, - reviewState: null, - reactions: comment.reactions ?? [], - }), - ), + thread.comments.map((comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + })), ); } From 844ac4055e22616dbd54dcd4805392bfc2026932 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 05:47:49 -0600 Subject: [PATCH 08/16] fix(server): encode viewed-file lane key through Schema --- apps/server/src/pullRequest/pullRequestViewedFiles.ts | 4 +++- apps/server/src/sourceControl/GitHubCli.test.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 010cd149be..a3ea2a6658 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -7,6 +7,7 @@ import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { PullRequestOperationError, @@ -38,6 +39,7 @@ export const FILE_REVISIONS_CACHE_CAPACITY = 64; export const MAX_FILE_REVISION_PATHS = 1_000; const MAX_HOST_MARK_PREFLIGHT_SLICES = 8; const DISPLAY_DIGEST = /^[0-9a-f]{64}$/; +const encodeFilesViewedKey = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); interface FileRevisionsDependencies { readonly runFork: (effect: Effect.Effect) => unknown; @@ -395,7 +397,7 @@ export const make = (dependencies: Dependencies) => { // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert // lets two presses each make a gate of their own and neither wait on the other. Effect.suspend(() => { - const key = JSON.stringify([ + const key = encodeFilesViewedKey([ project.project.id, project.api.kind, project.host, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7f0af808bf..c7291cf833 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -52,7 +52,7 @@ it.effect("snapshots one credential with tracing suppressed and never exposes to args: ["auth", "token", "--hostname", "github.com"], env: { GH_DEBUG: "", GIT_TRACE: "0", GIT_CURL_VERBOSE: "0" }, }); - expect(JSON.stringify(credential)).not.toContain("snapshot-secret"); + expect(String(credential.token)).not.toContain("snapshot-secret"); mockRun.mockImplementationOnce((input) => Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")), ); @@ -84,7 +84,6 @@ it.effect("snapshots one credential with tracing suppressed and never exposes to const failure = yield* gh .snapshotCredential({ cwd: "/repo", host: "github.com" }) .pipe(Effect.flip); - expect(JSON.stringify(failure)).not.toContain("snapshot-secret"); expect(yield* encodeGitHubCliError(failure)).not.toContain("snapshot-secret"); }).pipe(Effect.provide(layer)), ); From e7e1c0fa5f231736e90631a998cd2dc0cbd163f7 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 06:03:54 -0600 Subject: [PATCH 09/16] fix(web): bind viewed-file writes to committed PR scope --- .../pullRequest/PullRequestCodeTab.tsx | 73 +---------- .../usePullRequestFilesViewed.test.tsx | 113 +++++++++++++++++- .../pullRequest/usePullRequestFilesViewed.ts | 34 ++++-- .../useViewedFileHeaderMetadata.test.tsx | 97 +++++++++++++++ .../useViewedFileHeaderMetadata.tsx | 74 ++++++++++++ 5 files changed, 308 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.test.tsx create mode 100644 apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index e02d54c4b1..1be60e4c45 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -66,7 +66,6 @@ import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; -import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, @@ -85,8 +84,9 @@ import { toggleFileDiffFoldForViewed, type DiffFoldOverride, } from "./pullRequestDiff.logic"; -import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; +import { PullRequestMetaLine } from "./pullRequestPresentation"; import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; +import { useViewedFileHeaderMetadata } from "./useViewedFileHeaderMetadata"; import { nextPendingReviewCommentId, pullRequestReviewKey, @@ -844,71 +844,10 @@ 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. - 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; - let additions = 0; - let deletions = 0; - for (const hunk of item.fileDiff.hunks) { - additions += hunk.additionLines; - deletions += hunk.deletionLines; - } - const path = resolveFileDiffPath(item.fileDiff); - if (additions === 0 && deletions === 0) { - const withheld = omittedFileStats.get(path); - if (withheld) ({ additions, deletions } = withheld); - } - const stat = ( - - ); - const viewedFiles = filesViewedRef.current; - if (!viewedFiles.enabled || !viewedFiles.isTrackable(path)) return stat; - const viewed = viewedFiles.isViewed(path); - const stale = viewedFiles.isStale(path); - return ( - - {stat} - {/* The header itself folds the file, so the tick keeps its press to itself. The - attribute is what the header's capture listener looks for. */} - - - ); - }, - [omittedFileStats], + const renderHeaderMetadata = useViewedFileHeaderMetadata( + filesViewed, + omittedFileStats, + setFileViewed, ); const diffViewOptions = useMemo( diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx index edee742242..57d0aad257 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx @@ -6,8 +6,8 @@ import { } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import * as Cause from "effect/Cause"; -import { act, StrictMode } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; +import { act, startTransition, StrictMode, Suspense } from "react"; +import { create, type ReactTestRenderer, type TestRendererOptions } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ @@ -57,10 +57,10 @@ function Probe(_props: { readonly view: PullRequestFilesViewedView }) { return null; } -function Surface() { +function Surface({ currentReference = reference }: { readonly currentReference?: PullRequestRef }) { const view = usePullRequestFilesViewed({ environmentId, - reference, + reference: currentReference, enabled: true, paths, evidence, @@ -69,6 +69,25 @@ function Surface() { return ; } +const otherReference: PullRequestRef = { ...reference, number: 43 }; +const neverResolves = new Promise(() => {}); +let suspendedRenders = 0; + +function SuspendAfterSurface(): never { + suspendedRenders += 1; + throw neverResolves; +} + +async function show(currentReference: PullRequestRef) { + await act(async () => + renderer!.update( + + + , + ), + ); +} + function view(): PullRequestFilesViewedView { return renderer!.root.findByType(Probe).props.view; } @@ -94,6 +113,7 @@ beforeEach(async () => { host.refresh.mockReset(); onWriteRejected.mockReset(); toastAdd.mockReset(); + suspendedRenders = 0; setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); act(() => { renderer = create( @@ -239,6 +259,91 @@ it("sends the account that owned a queued press even after the next account's an expect(view().isViewed("a.ts")).toBe(false); }); +it("flushes the committed old request on a switch and rejects an old callback after A → B → A", async () => { + const firstA = view().setViewed; + firstA("a.ts", true); + await show(otherReference); + expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { + ...reference, + expectedViewer: "bilal", + files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }], + }, + }); + + firstA("a.ts", false); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledTimes(1); + + await show(reference); + firstA("a.ts", false); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledTimes(1); + + view().setViewed("a.ts", false); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledTimes(2); + expect(setFilesViewed).toHaveBeenLastCalledWith({ + environmentId, + input: { + ...reference, + expectedViewer: "bilal", + files: [{ path: "a.ts", viewed: false, digest: "a".repeat(64) }], + }, + }); +}); + +it("rejects a retained press handler after unmount", async () => { + const oldHandler = view().setViewed; + await act(async () => renderer?.unmount()); + renderer = null; + oldHandler("a.ts", true); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).not.toHaveBeenCalled(); +}); + +it("keeps a queued press on A when a concurrent B render suspends before commit", async () => { + await act(async () => renderer?.unmount()); + // react-test-renderer's type declaration omits this supported concurrent-root test option. + const concurrent = { unstable_isConcurrent: true } as unknown as TestRendererOptions; + await act(async () => { + renderer = create( + + + + + , + concurrent, + ); + }); + + view().setViewed("a.ts", true); + await act(async () => { + startTransition(() => + renderer!.update( + + + + + + , + ), + ); + }); + expect(suspendedRenders).toBeGreaterThan(0); + expect(view().enabled).toBe(true); // A is still the committed view. + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { + ...reference, + expectedViewer: "bilal", + files: [{ path: "a.ts", viewed: true, digest: "a".repeat(64) }], + }, + }); +}); + it("hides cached marks and disables writes after an account read fails", async () => { host.data = { viewer: "bilal", diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts index 6671c251e0..a14bb5aff8 100644 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts +++ b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts @@ -1,6 +1,6 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -130,14 +130,20 @@ export function usePullRequestFilesViewed(options: { reference.number, viewer ?? null, ]); - const scope = useRef(scopeKey); + // Object identity also fences A → B → A: a callback retained from the first A must not + // enqueue into the later A merely because the serialized key happens to match again. + const owner = useMemo(() => ({ key: scopeKey }), [scopeKey]); + const committedOwner = useRef(owner); // The host's answer as it stood when a completed write was acknowledged, per path. The first // answer that differs from it is the first read that could have seen the write, which is what // retires the press rather than the host happening to agree with it. const answeredFrom = useRef>(new Map()); const statesRef = useRef(states); - statesRef.current = states; + useLayoutEffect(() => { + committedOwner.current = owner; + statesRef.current = states; + }, [owner, states]); useEffect(() => { const pending = new Set([...queued.current.keys(), ...sentBy.current.keys()]); @@ -155,7 +161,7 @@ export function usePullRequestFilesViewed(options: { const batch = toFileViewedBatch(queued.current); if (batch.length === 0) return; queued.current = new Map(); - const sentFrom = scope.current; + const sentFrom = owner; const request = ++requests.current; for (const file of batch) sentBy.current.set(file.path, request); void setFilesViewed({ @@ -167,7 +173,7 @@ export function usePullRequestFilesViewed(options: { .filter((path) => sentBy.current.get(path) === request); for (const path of mine) sentBy.current.delete(path); // The reader has moved on, and what is on screen now has nothing to do with this answer. - if (scope.current !== sentFrom) return; + if (committedOwner.current !== sentFrom) return; if (result._tag === "Failure") { // 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 @@ -188,24 +194,26 @@ export function usePullRequestFilesViewed(options: { for (const path of mine) answeredFrom.current.set(path, statesRef.current); refresh(); }); - }, [environmentId, reference, refresh, setFilesViewed, viewer, onWriteRejected]); + }, [environmentId, reference, refresh, setFilesViewed, viewer, onWriteRejected, owner]); // 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; + useLayoutEffect(() => { + flushRef.current = flush; + }, [flush]); // 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(() => { + useLayoutEffect(() => { const flushScope = flushRef.current; - scope.current = scopeKey; return () => { if (flushTimer.current !== null) { clearTimeout(flushTimer.current); flushScope(); } + committedOwner.current = null; queued.current = new Map(); sentBy.current = new Map(); answeredFrom.current = new Map(); @@ -215,7 +223,9 @@ export function usePullRequestFilesViewed(options: { }, [scopeKey]); const refreshRef = useRef(refresh); - refreshRef.current = refresh; + useLayoutEffect(() => { + refreshRef.current = refresh; + }, [refresh]); const refreshFromHost = useCallback(() => refreshRef.current(), []); // Provider CLI sign-in can change outside this tab. Refresh on focus and while the tab is // visible so an account switch replaces the old reader's cached marks without a page reload. @@ -237,7 +247,7 @@ export function usePullRequestFilesViewed(options: { const setViewed = useCallback( (path: string, viewed: boolean) => { - if (!accountReady) return; + if (!accountReady || committedOwner.current !== owner) return; const shown = evidence.get(path); if (shown === undefined) return; setOverlayDigests((current) => new Map(current).set(path, shown.digest)); @@ -250,7 +260,7 @@ export function usePullRequestFilesViewed(options: { if (flushTimer.current !== null) clearTimeout(flushTimer.current); flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS); }, - [accountReady, evidence], + [accountReady, evidence, owner], ); const isTrackable = useCallback( diff --git a/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.test.tsx b/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.test.tsx new file mode 100644 index 0000000000..fbb2116e9e --- /dev/null +++ b/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.test.tsx @@ -0,0 +1,97 @@ +import { parsePatchFiles, type CodeViewItem } from "@pierre/diffs"; +import { act, memo, StrictMode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("../ui/checkbox", () => ({ + Checkbox: ({ checked }: { checked: boolean }) => ( + + ), +})); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipPopup: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("./pullRequestPresentation", () => ({ + PullRequestDiffStat: () => stat, +})); + +import type { PullRequestFilesViewedView } from "./usePullRequestFilesViewed"; +import { useViewedFileHeaderMetadata } from "./useViewedFileHeaderMetadata"; + +const fileDiff = parsePatchFiles( + "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\n", +)[0]!.files[0]!; +const item = { id: "file-a", type: "diff", fileDiff } satisfies CodeViewItem; +const omitted = new Map(); +const setFileViewed = vi.fn(); +let portalRenders = 0; +let renderer: ReactTestRenderer | null = null; + +function answer(enabled: boolean, viewed: boolean): PullRequestFilesViewedView { + return { + enabled, + isViewed: () => viewed, + isTrackable: () => true, + isStale: () => false, + setViewed: vi.fn(), + viewedCount: viewed ? 1 : 0, + truncated: false, + error: null, + refresh: vi.fn(), + }; +} + +// Pierre's SlotPortals memoizes its children by renderer identity and item key. This models +// that boundary without starting its DOM/worker renderer in a react-test-renderer test. +const Portal = memo(function Portal({ + render, +}: { + render: (item: CodeViewItem) => React.ReactNode; +}) { + portalRenders += 1; + return render(item); +}); + +function Surface({ state }: { state: PullRequestFilesViewedView }) { + const render = useViewedFileHeaderMetadata(state, omitted, setFileViewed); + return ; +} + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + portalRenders = 0; +}); + +it("repaints a memoized file header on viewed and account-disabled answers", async () => { + act(() => { + renderer = create( + + + , + ); + }); + expect(renderer!.root.findByType("input").props.checked).toBe(false); + const initialRenders = portalRenders; + + await act(async () => + renderer!.update( + + + , + ), + ); + expect(portalRenders).toBeGreaterThan(initialRenders); + expect(renderer!.root.findByType("input").props.checked).toBe(true); + + await act(async () => + renderer!.update( + + + , + ), + ); + expect(renderer!.root.findAllByType("input")).toHaveLength(0); +}); diff --git a/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.tsx b/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.tsx new file mode 100644 index 0000000000..8c12a04935 --- /dev/null +++ b/apps/web/src/components/pullRequest/useViewedFileHeaderMetadata.tsx @@ -0,0 +1,74 @@ +import type { CodeViewItem } from "@pierre/diffs"; +import type { PullRequestOmittedFileStat } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { resolveFileDiffPath } from "~/lib/diffRendering"; + +import { Checkbox } from "../ui/checkbox"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestDiffStat } from "./pullRequestPresentation"; +import type { PullRequestFilesViewedView } from "./usePullRequestFilesViewed"; + +/** The callback identity is part of Pierre's portal cache key. Its displayed answer is a + * dependency so a committed account/read change redraws the checkbox immediately. */ +export function useViewedFileHeaderMetadata( + filesViewed: PullRequestFilesViewedView, + omittedFileStats: ReadonlyMap, + setFileViewed: (fileKey: string, path: string, viewed: boolean) => void, +) { + return useCallback( + (item: CodeViewItem) => { + if (item.type !== "diff") return null; + let additions = 0; + let deletions = 0; + for (const hunk of item.fileDiff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + const path = resolveFileDiffPath(item.fileDiff); + if (additions === 0 && deletions === 0) { + const withheld = omittedFileStats.get(path); + if (withheld) ({ additions, deletions } = withheld); + } + const stat = ( + + ); + if (!filesViewed.enabled || !filesViewed.isTrackable(path)) return stat; + const viewed = filesViewed.isViewed(path); + const stale = filesViewed.isStale(path); + return ( + + {stat} + + + ); + }, + [filesViewed, omittedFileStats, setFileViewed], + ); +} From 466267ea1e12d1825adedccdbe57ffd0a055ecfa Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 06:28:57 -0600 Subject: [PATCH 10/16] Start provider event worker before service exposure --- .../provider/Layers/ProviderService.test.ts | 29 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 4 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1e228af47d..a961d5ab50 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -57,6 +57,7 @@ import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -6917,6 +6918,34 @@ class RuntimeReaper extends Context.Service()( describe("agent browser access", () => { const projectId = ProjectId.make("project-browser-access"); + it.effect("subscribes to adapter events before exposing the provider service", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const events = yield* PubSub.unbounded(); + let subscribed = false; + const adapter: ProviderAdapterShape = { + ...codex.adapter, + streamEvents: Stream.unwrap( + Effect.map(PubSub.subscribe(events), (subscription) => { + // This runs only after PubSub has registered the subscription, not merely when + // the stream wrapper is constructed. + subscribed = true; + return Stream.fromSubscription(subscription); + }), + ), + }; + yield* Effect.gen(function* () { + yield* ProviderService.ProviderService; + assert.isTrue(subscribed); + }).pipe(Effect.provide(makeAgentBrowserProviderLayer(false, { ...codex, adapter }, {}))); + }).pipe( + // Keep this one fiber from yielding before the assertion. The old deferred fork then + // reproducibly leaves `subscribed` false; immediate startup passes without a timer. + Effect.provideService(Scheduler.PreventSchedulerYield, true), + Effect.provide(NodeServices.layer), + ), + ); + const makeBrowserAccessProjectionLayer = ( threadId: ThreadId, status: "found" | "missing" | "failed" = "found", diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2fc810c8a3..e999423390 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1758,6 +1758,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Publish the new adapter set before any replacement stream can emit. yield* Ref.set(subscribedAdapters, next); for (const [id, adapter] of newSubscriptions) { + // A provider can emit a receipt synchronously during an API call. Subscribe before + // returning from reconciliation so that first receipt cannot outrun a deferred fork. yield* Stream.runForEach(adapter.streamEvents, (event) => processRuntimeEvent( { @@ -1767,7 +1769,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, event, ), - ).pipe(Effect.forkScoped); + ).pipe(Effect.forkScoped({ startImmediately: true })); } }); From f214e3036b6bd1878026b1a079499af8295fa66f Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:37:19 -0600 Subject: [PATCH 11/16] fix(server): protect generated thread titles with persisted intent --- .../Layers/ProjectionPipeline.ts | 7 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 12 ++ .../Layers/ProviderCommandReactor.test.ts | 88 +++++++++++---- .../Layers/ProviderCommandReactor.ts | 21 ++-- .../decider.titleRegeneration.test.ts | 105 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 53 ++++++++- apps/server/src/orchestration/projector.ts | 3 + .../persistence/Layers/ProjectionThreads.ts | 8 +- apps/server/src/persistence/Migrations.ts | 2 + .../064_ProjectionThreadTitleState.test.ts | 23 ++++ .../064_ProjectionThreadTitleState.ts | 12 ++ .../persistence/Services/ProjectionThreads.ts | 2 + .../client-runtime/src/state/threadReducer.ts | 7 ++ packages/contracts/src/orchestration.ts | 23 ++++ 15 files changed, 333 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts create mode 100644 apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index c6aefdfe33..d947817a13 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -606,6 +606,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + titleState: event.payload.titleState ?? null, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -649,6 +650,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, archivedAt: event.payload.archivedAt, + ...(event.payload.titleState !== undefined + ? { titleState: event.payload.titleState } + : {}), titleRegenerationRequestId: null, titleRegenerationStartedAt: null, updatedAt: event.payload.updatedAt, @@ -803,6 +807,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleState !== undefined + ? { titleState: event.payload.titleState } + : {}), ...(event.payload.activeOrderKey !== undefined ? { activeOrderKey: event.payload.activeOrderKey } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index bdb882dcc0..b3953b854b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -510,6 +510,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinOrderKey: "gm", activeOrderKey: "hq", titleRegeneration: null, + titleState: null, continuedFromThreadId: null, deletedAt: null, messages: [ @@ -646,6 +647,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinOrderKey: "gm", activeOrderKey: "hq", titleRegeneration: null, + titleState: null, continuedFromThreadId: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 097ac839b2..57d2ef32e5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -33,6 +33,7 @@ import { ModelSelection, ProjectId, ThreadLinkedPullRequest, + ThreadTitleState, ThreadId, ThreadPullRequestSnapshot, ThreadPullRequestStack, @@ -139,6 +140,7 @@ const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFi const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + titleState: Schema.NullOr(Schema.fromJsonString(ThreadTitleState)), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), @@ -700,6 +702,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -744,6 +747,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -790,6 +794,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -1442,6 +1447,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -2766,6 +2772,7 @@ pending_approval_requests AS ( pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + titleState: row.titleState, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -3016,6 +3023,7 @@ pending_approval_requests AS ( pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + titleState: row.titleState, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -3177,6 +3185,7 @@ pending_approval_requests AS ( pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -3348,6 +3357,7 @@ pending_approval_requests AS ( pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -3728,6 +3738,7 @@ pending_approval_requests AS ( pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + titleState: threadRow.value.titleState, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -4046,6 +4057,7 @@ pending_approval_requests AS ( pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + titleState: threadRow.value.titleState, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3be323be0..fa563f7e18 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -213,6 +213,7 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; + readonly initialThreadTitle?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; @@ -703,7 +704,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: input?.initialThreadTitle ?? "Thread", modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -2840,9 +2841,9 @@ describe("ProviderCommandReactor", () => { ); it("retries thread title generation after a transient failure", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; + const harness = await createHarness({ initialThreadTitle: seededTitle }); + const now = "2026-01-01T00:00:00.000Z"; let attempts = 0; harness.generateThreadTitle.mockReturnValue( Effect.suspend(() => { @@ -2858,15 +2859,6 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -2903,6 +2895,65 @@ describe("ProviderCommandReactor", () => { expect(attempts).toBe(2); }); + it("keeps an explicit rename made while the first title generation is in flight", async () => { + const seededTitle = "Investigate reconnect failures"; + const harness = await createHarness({ initialThreadTitle: seededTitle }); + const generationStarted = await Effect.runPromise(Deferred.make()); + const releaseGeneration = await Effect.runPromise(Deferred.make()); + harness.generateThreadTitle.mockReturnValue( + Deferred.succeed(generationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseGeneration)), + Effect.as({ title: "Generated reconnect title" }), + ), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-title-race-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-title-race"), + role: "user", + text: "Investigate reconnect failures", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await Effect.runPromise(Deferred.await(generationStarted)); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-title-race-rename-b"), + threadId: ThreadId.make("thread-1"), + title: "Review reconnect state", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-title-race-rename-a"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }), + ); + await Effect.runPromise(Deferred.succeed(releaseGeneration, undefined)); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.title).toBe(seededTitle); + expect(thread?.titleState).toEqual({ + source: "manual", + version: CommandId.make("cmd-title-race-rename-a"), + }); + }); + it("regenerates a thread title from the current conversation", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -3614,9 +3665,9 @@ describe("ProviderCommandReactor", () => { }); it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Fix reconnect spinner on resume"; + const harness = await createHarness({ initialThreadTitle: seededTitle }); + const now = "2026-01-01T00:00:00.000Z"; const prompt = `[effort:high]\\n\\nFix reconnect spinner on resume ${serializeAssistantCitation(assistantCitation)}`; harness.generateThreadTitle.mockReturnValue( Effect.succeed({ @@ -3624,15 +3675,6 @@ describe("ProviderCommandReactor", () => { }), ); - await harness.runEffect( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-formatted-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - const titleUpdated = await harness.runEffect( harness.engine.streamDomainEvents.pipe( Stream.filter( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e5fcf3d49c..fe9f7924cc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1435,6 +1435,8 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly titleSeed?: string; + readonly expectedTitle: string; + readonly expectedVersion: CommandId; }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { @@ -1457,17 +1459,14 @@ const make = Effect.gen(function* () { ); if (!generated) return; - const thread = yield* resolveThreadShell(input.threadId); - if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; - } - + if (generated.title === DEFAULT_THREAD_TITLE) return; yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", + type: "thread.title.generate.complete", commandId: yield* serverCommandId("thread-title-rename"), threadId: input.threadId, title: generated.title, + expectedTitle: input.expectedTitle, + expectedVersion: input.expectedVersion, }); }).pipe( Effect.catchCause((cause) => @@ -1952,10 +1951,16 @@ const make = Effect.gen(function* () { worktreePath: thread.worktreePath, ...generationInput, }).pipe(Effect.forkScoped); - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + if ( + thread.titleState != null && + thread.titleState.source !== "manual" && + canReplaceThreadTitle(thread.title, event.payload.titleSeed) + ) { yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, cwd: generationCwd, + expectedTitle: thread.title, + expectedVersion: thread.titleState.version, ...generationInput, }).pipe(Effect.forkScoped); } diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index c032f33d0d..fcbebeb5bf 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -47,6 +47,111 @@ const readModel: OrchestrationReadModel = { }; it.layer(NodeServices.layer)("title regeneration decider", (it) => { + const generatedCompletion = (model: OrchestrationReadModel, expectedVersion: CommandId) => + decideOrchestrationCommand({ + command: { + type: "thread.title.generate.complete", + commandId: CommandId.make("cmd-generation-complete"), + threadId: ThreadId.make("thread-1"), + expectedTitle: "Manual title", + expectedVersion, + title: "Generated title", + }, + readModel: model, + }); + + it.effect("keeps a manual rename even when its text returns to the original title", () => + Effect.gen(function* () { + const thread = readModel.threads[0]!; + const renamed = { + ...readModel, + threads: [ + { + ...thread, + titleState: { source: "manual" as const, version: CommandId.make("cmd-rename-again") }, + }, + ], + }; + const result = yield* generatedCompletion(renamed, CommandId.make("cmd-original-title")); + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + expect(event.payload.titleState).toBeUndefined(); + expect(event.payload.updatedAt).toBe(UPDATED_AT); + } + }), + ); + + it.effect("accepts only the current generated version and an active thread", () => + Effect.gen(function* () { + const thread = readModel.threads[0]!; + const version = CommandId.make("cmd-earlier-generated-title"); + const current = { + ...readModel, + threads: [{ ...thread, titleState: { source: "generated" as const, version } }], + }; + const accepted = yield* generatedCompletion(current, version); + const acceptedEvent = Array.isArray(accepted) ? accepted[0] : accepted; + expect(acceptedEvent.type).toBe("thread.meta-updated"); + if (acceptedEvent.type === "thread.meta-updated") { + expect(acceptedEvent.payload.title).toBe("Generated title"); + expect(acceptedEvent.payload.titleState).toEqual({ + source: "generated", + version: CommandId.make("cmd-generation-complete"), + }); + } + for (const changed of [ + { titleState: { source: "generated" as const, version: CommandId.make("cmd-newer") } }, + { archivedAt: UPDATED_AT }, + { deletedAt: UPDATED_AT }, + { titleRegeneration: { requestId: CommandId.make("cmd-regen"), startedAt: UPDATED_AT } }, + ]) { + const result = yield* generatedCompletion( + { ...readModel, threads: [{ ...thread, titleState: current.threads[0]!.titleState, ...changed }] }, + version, + ); + const event = Array.isArray(result) ? result[0] : result; + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + expect(event.payload.updatedAt).toBe(UPDATED_AT); + } + } + }), + ); + + it.effect("invalidates a pending title when a thread is archived and later reopened", () => + Effect.gen(function* () { + const version = CommandId.make("cmd-title-before-archive"); + const thread = { + ...readModel.threads[0]!, + titleState: { source: "generated" as const, version }, + }; + const archive = yield* decideOrchestrationCommand({ + command: { + type: "thread.archive", + commandId: CommandId.make("cmd-archive-during-title"), + threadId: thread.id, + }, + readModel: { ...readModel, threads: [thread] }, + }); + const archiveEvent = Array.isArray(archive) ? archive[0] : archive; + expect(archiveEvent.type).toBe("thread.archived"); + if (archiveEvent.type !== "thread.archived") return; + expect(archiveEvent.payload.titleState).toEqual({ + source: "generated", + version: CommandId.make("cmd-archive-during-title"), + }); + const reopened = { + ...readModel, + threads: [{ ...thread, titleState: archiveEvent.payload.titleState, archivedAt: null }], + }; + const result = yield* generatedCompletion(reopened, version); + const event = Array.isArray(result) ? result[0] : result; + if (event.type === "thread.meta-updated") expect(event.payload.title).toBeUndefined(); + }), + ); + it.effect("preserves updatedAt for a stale completion", () => Effect.gen(function* () { const result = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 8c9a180a60..af8efc8ea6 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -493,6 +493,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + titleState: { source: "provisional" as const, version: command.commandId }, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -530,7 +531,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.archive": { - yield* requireThreadNotArchived({ + const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, @@ -548,6 +549,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, archivedAt: occurredAt, updatedAt: occurredAt, + titleState: { + source: thread.titleState?.source ?? "manual", + version: command.commandId, + }, }, }; } @@ -1120,7 +1125,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, - ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.title !== undefined + ? { + title: command.title, + titleState: { source: "manual" as const, version: command.commandId }, + } + : {}), ...(command.regenerateTitle === true ? { regenerateTitle: true as const, @@ -1336,6 +1346,38 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.title.generate.complete": { + const thread = yield* requireThread({ readModel, command, threadId: command.threadId }); + const current = + thread.deletedAt === null && + thread.archivedAt === null && + thread.titleRegeneration == null && + thread.titleState != null && + thread.titleState.source !== "manual" && + thread.title === command.expectedTitle && + thread.titleState.version === command.expectedVersion; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + ...(current + ? { + title: command.title, + titleState: { source: "generated" as const, version: command.commandId }, + } + : {}), + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, @@ -1354,7 +1396,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, - ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), + ...(requestIsCurrent && command.title !== undefined + ? { + title: command.title, + titleState: { source: "generated" as const, version: command.commandId }, + } + : {}), ...(requestIsCurrent ? { titleRegeneration: null } : {}), updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 656f7758c2..f345e23649 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -425,6 +425,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + titleState: payload.titleState ?? null, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -480,6 +481,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { archivedAt: payload.archivedAt, titleRegeneration: null, + ...(payload.titleState !== undefined ? { titleState: payload.titleState } : {}), updatedAt: payload.updatedAt, }), })), @@ -616,6 +618,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleState !== undefined ? { titleState: payload.titleState } : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 8f2ff5edbd..cf90c96eb5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import { ModelSelection, ThreadLinkedPullRequest, ThreadTitleState } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + titleState: Schema.NullOr(Schema.fromJsonString(ThreadTitleState)), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), @@ -36,6 +37,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + title_state_json, model_selection_json, runtime_mode, interaction_mode, @@ -71,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.titleState == null ? null : JSON.stringify(row.titleState)}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -106,6 +109,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + title_state_json = excluded.title_state_json, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -148,6 +152,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -192,6 +197,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_state_json AS "titleState", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a50bb20be3..edbd99b369 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0059 from "./Migrations/059_ProjectionThreadPullRequests.ts"; import Migration0060 from "./Migrations/060_ProjectionThreadCompactionQueue.ts"; import Migration0061 from "./Migrations/061_ProjectionThreadMessageContext.ts"; import Migration0062 from "./Migrations/062_RepairStoppedSessionRequests.ts"; +import Migration0064 from "./Migrations/064_ProjectionThreadTitleState.ts"; /** * Migration loader with all migrations defined inline. * @@ -178,6 +179,7 @@ const migrationEntries = [ // Upstream 51 is already Pylon durable rollback; retain that lineage and append. [61, "ProjectionThreadMessageContext", Migration0061], [62, "RepairStoppedSessionRequests", Migration0062], + [64, "ProjectionThreadTitleState", Migration0064], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts new file mode 100644 index 0000000000..a023782ce1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))("064_ProjectionThreadTitleState", (it) => { + it.effect("adds nullable title provenance for historical rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 63 }); + yield* runMigrations({ toMigrationInclusive: 64 }); + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_threads) + `; + const titleState = columns.find((column) => column.name === "title_state_json"); + assert.ok(titleState); + assert.equal(titleState.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.ts b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.ts new file mode 100644 index 0000000000..70482ec18c --- /dev/null +++ b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Historical rows have no provable title provenance. Keep them null; future +// manual and generated title events record a command-correlated version. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + if (!columns.some((column) => column.name === "title_state_json")) { + yield* sql`ALTER TABLE projection_threads ADD COLUMN title_state_json TEXT`; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 23194421ad..679557a3ec 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -16,6 +16,7 @@ import { ProviderInteractionMode, RuntimeMode, ThreadLinkedPullRequest, + ThreadTitleState, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -30,6 +31,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 04f38a63b6..48b9b9354b 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -117,6 +117,7 @@ export function applyThreadDetailEvent( id: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + titleState: event.payload.titleState ?? null, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -157,6 +158,9 @@ export function applyThreadDetailEvent( ...thread, archivedAt: event.payload.archivedAt, titleRegeneration: null, + ...(event.payload.titleState !== undefined + ? { titleState: event.payload.titleState } + : {}), updatedAt: event.payload.updatedAt, }, }; @@ -260,6 +264,9 @@ export function applyThreadDetailEvent( thread: { ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleState !== undefined + ? { titleState: event.payload.titleState } + : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegeneration: event.payload.titleRegeneration } : {}), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c42f9fdde9..2c4efb9266 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -829,6 +829,13 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +// The version changes even when an explicit rename repeats the same text. +export const ThreadTitleState = Schema.Struct({ + source: Schema.Literals(["provisional", "manual", "generated"]), + version: CommandId, +}); +export type ThreadTitleState = typeof ThreadTitleState.Type; + export const ThreadTitleRegeneration = Schema.Struct({ requestId: CommandId, startedAt: IsoDateTime, @@ -1011,6 +1018,7 @@ export const OrchestrationThread = Schema.Struct({ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), /** * The thread this one continues, when work was handed off to another * provider account rather than resumed in place. @@ -1093,6 +1101,7 @@ export const OrchestrationThreadShell = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), /** * The thread this one continues. Carried on the shell as well as the detail * so a client can find a thread's continuation without loading every thread: @@ -1902,6 +1911,15 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadTitleGenerateCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.title.generate.complete"), + commandId: CommandId, + threadId: ThreadId, + expectedTitle: TrimmedNonEmptyString, + expectedVersion: CommandId, + title: TrimmedNonEmptyString, +}); + const ThreadPullRequestSyncCommand = Schema.Struct({ type: Schema.Literal("thread.pull-request.sync"), commandId: CommandId, @@ -1978,6 +1996,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnAdmissionAcceptCommand, ThreadTurnAdmissionFailCommand, ThreadTitleRegenerationCompleteCommand, + ThreadTitleGenerateCompleteCommand, ThreadPullRequestSyncCommand, ThreadPullRequestLinkSyncCommand, ]); @@ -2069,6 +2088,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + titleState: Schema.optional(ThreadTitleState), modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( @@ -2090,6 +2110,8 @@ export const ThreadArchivedPayload = Schema.Struct({ threadId: ThreadId, archivedAt: IsoDateTime, updatedAt: IsoDateTime, + // Cancels any in-flight first-title generation across archive/reopen. + titleState: Schema.optional(ThreadTitleState), }); export const ThreadUnarchivedPayload = Schema.Struct({ @@ -2159,6 +2181,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ previousTitle: Schema.optional(TrimmedNonEmptyString), /** Pending state shared with clients. Null clears a matching request. */ titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), + titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From a46c4b41b29e234948e20aadcc9a5acdc024960b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:41:47 -0600 Subject: [PATCH 12/16] test(server): preserve imported and legacy title behavior --- .../src/orchestration/decider.import.test.ts | 8 ++++- .../decider.titleRegeneration.test.ts | 34 ++++++++++++++++++- apps/server/src/orchestration/decider.ts | 10 ++++-- .../064_ProjectionThreadTitleState.test.ts | 4 +++ packages/contracts/src/orchestration.test.ts | 33 ++++++++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts index c809c73380..e027400e3c 100644 --- a/apps/server/src/orchestration/decider.import.test.ts +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -69,8 +69,14 @@ it.layer(NodeServices.layer)("thread history import", (it) => { expect(imported).toMatchObject({ type: "thread.created", metadata: { historyImport: true }, + payload: { + titleState: { source: "manual", version: `command-create-import:codex:session-1` }, + }, + }); + expect(live).toMatchObject({ + type: "thread.created", + payload: { titleState: { source: "provisional", version: "command-create-live-thread" } }, }); - expect(live).toMatchObject({ type: "thread.created" }); expect(live).not.toMatchObject({ metadata: { historyImport: true } }); }), ); diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index fcbebeb5bf..f289eeb5f6 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -108,7 +108,10 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { { titleRegeneration: { requestId: CommandId.make("cmd-regen"), startedAt: UPDATED_AT } }, ]) { const result = yield* generatedCompletion( - { ...readModel, threads: [{ ...thread, titleState: current.threads[0]!.titleState, ...changed }] }, + { + ...readModel, + threads: [{ ...thread, titleState: current.threads[0]!.titleState, ...changed }], + }, version, ); const event = Array.isArray(result) ? result[0] : result; @@ -175,4 +178,33 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { } }), ); + + it.effect("ignores regeneration completion after archive or deletion", () => + Effect.gen(function* () { + const requestId = CommandId.make("cmd-regenerate-pending"); + const thread = { + ...readModel.threads[0]!, + titleRegeneration: { requestId, startedAt: UPDATED_AT }, + }; + for (const inactive of [{ archivedAt: UPDATED_AT }, { deletedAt: UPDATED_AT }]) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.title.regeneration.complete", + commandId: CommandId.make("cmd-regeneration-late"), + threadId: thread.id, + requestId, + title: "Generated title", + }, + readModel: { ...readModel, threads: [{ ...thread, ...inactive }] }, + }); + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + expect(event.payload.titleState).toBeUndefined(); + expect(event.payload.updatedAt).toBe(UPDATED_AT); + } + } + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index af8efc8ea6..789f73c65f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -493,7 +493,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, - titleState: { source: "provisional" as const, version: command.commandId }, + titleState: { + source: command.historyImport === true ? ("manual" as const) : ("provisional" as const), + version: command.commandId, + }, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -1384,7 +1387,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId; + const requestIsCurrent = + thread.deletedAt === null && + thread.archivedAt === null && + thread.titleRegeneration?.requestId === command.requestId; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ diff --git a/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts index a023782ce1..9ee9d45e39 100644 --- a/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts +++ b/apps/server/src/persistence/Migrations/064_ProjectionThreadTitleState.test.ts @@ -11,6 +11,10 @@ it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))("064_ProjectionThreadTi Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 63 }); + const viewedTables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pull_request_files_viewed' + `; + assert.equal(viewedTables.length, 1); yield* runMigrations({ toMigrationInclusive: 64 }); const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` PRAGMA table_info(projection_threads) diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 5e9a330a68..23c8363b6d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -653,6 +653,39 @@ it.effect("decodes thread.created runtime mode for historical events", () => }), ); +it.effect("keeps title intent optional for older event payload consumers", () => + Effect.gen(function* () { + const { titleState: _createdTitleState, ...legacyCreatedFields } = ThreadCreatedPayload.fields; + const { titleState: _updatedTitleState, ...legacyUpdatedFields } = + ThreadMetaUpdatedPayload.fields; + const legacyCreated = Schema.decodeUnknownEffect(Schema.Struct(legacyCreatedFields)); + const legacyUpdated = Schema.decodeUnknownEffect(Schema.Struct(legacyUpdatedFields)); + const created = yield* legacyCreated({ + threadId: "thread-1", + projectId: "project-1", + title: "Seed title", + titleState: { source: "provisional", version: "command-create" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const updated = yield* legacyUpdated({ + threadId: "thread-1", + title: "Generated title", + titleState: { source: "generated", version: "command-generation" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }); + assert.strictEqual(created.title, "Seed title"); + assert.strictEqual(updated.title, "Generated title"); + assert.strictEqual("titleState" in created, false); + assert.strictEqual("titleState" in updated, false); + }), +); + it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ From 7887c9293dc540af5db4507eb43bc4ee4c93606c Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:49:02 -0600 Subject: [PATCH 13/16] fix(server): apply title intent to provider metadata --- .../Layers/ProjectionSnapshotQuery.test.ts | 18 ++++- .../Layers/ProjectionSnapshotQuery.ts | 4 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 65 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 14 +++- .../Services/ProjectionSnapshotQuery.ts | 4 +- .../decider.titleRegeneration.test.ts | 27 ++++++++ apps/server/src/orchestration/decider.ts | 5 +- 7 files changed, 131 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b3953b854b..64697df155 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,6 +1,7 @@ import { type AgentSessionImportSource, ChatAttachment, + CommandId, ComposerContextId, CheckpointRef, EventId, @@ -783,7 +784,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", - session: snapshot.threads[0]?.session, + titleState: null, + session: snapshot.threads[0]?.session ?? null, + }); + } + + yield* sql` + UPDATE projection_threads + SET title_state_json = '{"source":"manual","version":"cmd-manual-title"}' + WHERE thread_id = 'thread-1' + `; + const ownedContext = yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-1")); + assert.equal(ownedContext._tag, "Some"); + if (ownedContext._tag === "Some") { + assert.deepEqual(ownedContext.value.titleState, { + source: "manual", + version: CommandId.make("cmd-manual-title"), }); } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 57d2ef32e5..11e9f95597 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -173,6 +173,7 @@ const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: Schema.String, + titleState: Schema.NullOr(Schema.fromJsonString(ThreadTitleState)), session: Schema.NullOr(ProjectionThreadSessionDbRowSchema), }); const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( @@ -1516,6 +1517,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.thread_id AS id, threads.project_id AS "projectId", threads.title, + threads.title_state_json AS "titleState", sessions.thread_id AS "threadId", sessions.status, sessions.provider_name AS "providerName", @@ -1557,6 +1559,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.id, projectId: row.projectId, title: row.title, + titleState: row.titleState, session: row.threadId === null ? null : row, })), ), @@ -3768,6 +3771,7 @@ pending_approval_requests AS ( id: row.id, projectId: row.projectId, title: row.title, + titleState: row.titleState, session: row.session === null ? null : mapSessionRow(row.session), })); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9bb1d25e74..1df1588ead 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -5829,6 +5829,71 @@ describe("ProviderRuntimeIngestion", () => { (entry) => entry.title === "Renamed by provider", ); expect(thread.title).toBe("Renamed by provider"); + expect(thread.titleState?.source).toBe("generated"); + }); + + it("does not replace an explicit default-title rename with late provider metadata", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + await harness.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-user-renamed-to-default"), + threadId: ThreadId.make("thread-1"), + title: DEFAULT_THREAD_TITLE, + }); + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-after-manual-default"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + payload: { name: "Renamed by provider", metadata: { source: "provider" } }, + }); + + await harness.drain(); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.title).toBe(DEFAULT_THREAD_TITLE); + expect(thread?.titleState).toEqual({ + source: "manual", + version: CommandId.make("cmd-user-renamed-to-default"), + }); + }); + + it("does not let placeholder provider metadata supersede a pending generated title", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + const before = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(before?.titleState?.source).toBe("provisional"); + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-placeholder"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + payload: { name: DEFAULT_THREAD_TITLE, metadata: { source: "provider" } }, + }); + await harness.drain(); + + const after = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(after?.titleState).toEqual(before?.titleState); + await harness.dispatch({ + type: "thread.title.generate.complete", + commandId: CommandId.make("cmd-pending-real-title"), + threadId: ThreadId.make("thread-1"), + expectedTitle: DEFAULT_THREAD_TITLE, + expectedVersion: before!.titleState!.version, + title: "Real generated title", + }); + const accepted = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(accepted?.title).toBe("Real generated title"); }); it("rejects a provider title once the thread has a real title", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7aea6f4d5d..dc03eee6f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -71,7 +71,7 @@ import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { canReplaceThreadTitle } from "../threadTitles.ts"; +import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; class PrivateProviderRuntimeEventFence extends Context.Service< PrivateProviderRuntimeEventFence, @@ -3043,12 +3043,20 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - if (canReplaceThreadTitle(thread.title)) { + if ( + event.payload.name !== DEFAULT_THREAD_TITLE && + event.payload.name !== thread.title && + thread.titleState != null && + thread.titleState.source !== "manual" && + canReplaceThreadTitle(thread.title) + ) { yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", + type: "thread.title.generate.complete", commandId: yield* providerCommandId(event, "thread-meta-update"), threadId: thread.id, title: event.payload.name, + expectedTitle: thread.title, + expectedVersion: thread.titleState.version, }); } } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index aa61774e92..948f449c70 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -247,7 +247,9 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadRuntimeContext: ( threadId: ThreadId, ) => Effect.Effect< - Option.Option>, + Option.Option< + Pick + >, ProjectionRepositoryError >; diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index f289eeb5f6..e7d3a3274b 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -123,6 +123,33 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { }), ); + it.effect("does not rotate intent for an unchanged generated title", () => + Effect.gen(function* () { + const version = CommandId.make("cmd-pending-title"); + const thread = { + ...readModel.threads[0]!, + titleState: { source: "provisional" as const, version }, + }; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.title.generate.complete", + commandId: CommandId.make("cmd-placeholder-title"), + threadId: thread.id, + expectedTitle: thread.title, + expectedVersion: version, + title: thread.title, + }, + readModel: { ...readModel, threads: [thread] }, + }); + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + expect(event.payload.titleState).toBeUndefined(); + } + }), + ); + it.effect("invalidates a pending title when a thread is archived and later reopened", () => Effect.gen(function* () { const version = CommandId.make("cmd-title-before-archive"); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 789f73c65f..ee916fea5b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -48,6 +48,7 @@ import { } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; +import { DEFAULT_THREAD_TITLE } from "./threadTitles.ts"; const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); @@ -1358,7 +1359,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.titleState != null && thread.titleState.source !== "manual" && thread.title === command.expectedTitle && - thread.titleState.version === command.expectedVersion; + thread.titleState.version === command.expectedVersion && + command.title !== thread.title && + command.title !== DEFAULT_THREAD_TITLE; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ From ce7c6f66ab9061e4a0f1ea0c0ca13917880c5c2b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:56:06 -0600 Subject: [PATCH 14/16] test(server): use reactor harness for response dispatches --- .../orchestration/Layers/ProviderCommandReactor.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fa563f7e18..4de8d62ede 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -5529,7 +5529,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness({ unreadableHistory: true }); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-approval"), @@ -5547,7 +5547,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.approval.respond", commandId: CommandId.make("cmd-approval-respond"), @@ -5570,7 +5570,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness({ unreadableHistory: true }); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input"), @@ -5588,7 +5588,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond"), From 07f289dd155e1eeb109a27ee3f1b868aa07116b1 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 07:59:41 -0600 Subject: [PATCH 15/16] fix(server): redact title generation failure logs --- .../Layers/ProviderCommandReactor.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe9f7924cc..611e67c3c3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1472,8 +1472,8 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename thread title", { threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }), ), ); @@ -1578,7 +1578,8 @@ const make = Effect.gen(function* () { "provider command reactor failed to clear interrupted title regeneration", { threadId, - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }, ); }), @@ -1604,7 +1605,8 @@ const make = Effect.gen(function* () { } return Effect.logWarning("provider command reactor failed to regenerate thread title", { threadId: event.payload.threadId, - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); }), ); @@ -1626,7 +1628,8 @@ const make = Effect.gen(function* () { "provider command reactor retrying title regeneration completion", { threadId: event.payload.threadId, - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }, ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))); }), @@ -1642,7 +1645,8 @@ const make = Effect.gen(function* () { "provider command reactor failed to complete title regeneration", { threadId: event.payload.threadId, - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }, ); }), @@ -2757,7 +2761,10 @@ const make = Effect.gen(function* () { } return Effect.logWarning( "provider command reactor failed to find interrupted title regenerations", - { cause: Cause.pretty(cause) }, + { + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, + }, ).pipe(Effect.as([])); }), ); @@ -2795,7 +2802,8 @@ const make = Effect.gen(function* () { return Effect.logWarning( "provider command reactor failed to clear interrupted title regenerations", { - cause: Cause.pretty(cause), + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, }, ); }), From ad4bc67571b45d846ed31d3e198ea840fdbd8b84 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 08:14:46 -0600 Subject: [PATCH 16/16] test(server): include title state in thread projection fixture --- apps/server/src/orchestration/projector.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 796c5b4774..71735bc3d7 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -78,6 +78,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + titleState: null, modelSelection: { instanceId: "codex", model: "gpt-5-codex",