authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-17 14:33:26-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 15:33:26-03:00
log03950089ffa5cecf0ce731227f58551f6437495f
tree929c9d3e04a6bd6117ecf92d14f66fc1b09f7aba
parent8db3c250f41e8f1d0b36da6b5a5093821e72d4f3
signature Signed by PGP key B5690EEEBB952194

feat(web): pull request files can be marked as viewed (#7721)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: maria <maria@kuuro.net>

67 files changed, 9014 insertions(+), 200 deletions(-)

apps/mobile/src/features/review/reviewModel.ts+3-2
......@@ -1,6 +1,7 @@
11import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
22import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
33import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
4import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
45import * as Arr from "effect/Array";
56import { pipe } from "effect/Function";
67import * as Order from "effect/Order";
......@@ -372,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender
372373}
373374
374375function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
375 const path = file.name || file.prevName || "";
376 const previousPath = file.prevName || null;
376 const path = unquoteGitPatchPath(file.name || file.prevName || "");
377 const previousPath = file.prevName ? unquoteGitPatchPath(file.prevName) : null;
377378 const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0);
378379 const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0);
379380 const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`;
apps/server/package.json+1
......@@ -28,6 +28,7 @@
2828 "@effect/platform-node-shared": "catalog:",
2929 "@ff-labs/fff-node": "0.9.4",
3030 "@opencode-ai/sdk": "^1.3.15",
31 "diff": "8.0.3",
3132 "effect": "catalog:",
3233 "msgpackr-extract": "3.0.4",
3334 "node-pty": "^1.1.0",
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -76,6 +76,7 @@ export const RPC_REQUIRED_SCOPES = {
7676 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
7777 [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
7878 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
79 [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,
7980 [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
8081 [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
8182 [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
......@@ -84,6 +85,7 @@ export const RPC_REQUIRED_SCOPES = {
8485 [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,
8586 [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,
8687 [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope,
88 [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope,
8789 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
8890 // client pressing refresh must not be told it may not look again.
8991 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
apps/server/src/persistence/Errors.ts+1
......@@ -167,5 +167,6 @@ export type OrchestrationCommandReceiptRepositoryError =
167167export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError;
168168export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError;
169169export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
170export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError;
170171
171172export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
apps/server/src/persistence/Migrations.ts+2
......@@ -64,6 +64,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
6464import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
6565import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";
6666import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts";
67import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";
6768
6869/**
6970 * Migration loader with all migrations defined inline.
......@@ -128,6 +129,7 @@ const migrationEntries = [
128129 [50, "ProjectionThreadPullRequests", Migration0050],
129130 [51, "ProjectionThreadMessageContext", Migration0051],
130131 [52, "ProjectionThreadTitleState", Migration0052],
132 [53, "PullRequestFilesViewed", Migration0053],
131133] as const;
132134
133135export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts created+25
......@@ -0,0 +1,25 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 // One row per file a reader has cleared on a host that keeps no record of its own. `revision`
8 // is what the file was when it was cleared, so a push that changes it is reported as changed
9 // rather than silently left ticked, and it is nullable for the reason `PullRequestFileViewedMark`
10 // gives. Unticking deletes the row: absent is the resting state, and a table of "not viewed"
11 // rows would grow with every diff anybody scrolled past.
12 yield* sql`
13 CREATE TABLE IF NOT EXISTS pull_request_files_viewed (
14 provider TEXT NOT NULL,
15 host TEXT NOT NULL,
16 repository TEXT NOT NULL,
17 number INTEGER NOT NULL,
18 viewer TEXT NOT NULL,
19 path TEXT NOT NULL,
20 revision TEXT,
21 viewed_at TEXT NOT NULL,
22 PRIMARY KEY (provider, host, repository, number, viewer, path)
23 ) WITHOUT ROWID
24 `;
25});
apps/server/src/persistence/PullRequestFilesViewed.ts created+184
......@@ -0,0 +1,184 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6import * as SqlSchema from "effect/unstable/sql/SqlSchema";
7
8import { SourceControlProviderKind } from "@t3tools/contracts";
9
10import {
11 PersistenceDecodeError,
12 PersistenceSqlError,
13 type PullRequestFilesViewedRepositoryError,
14} from "./Errors.ts";
15
16/**
17 * Which change request, on which host, for which reader. The host is part of it because the same
18 * `group/project` exists on gitlab.com and on a self-managed instance, and the reader because
19 * signing in as somebody else must not inherit their ticks. A host that will not say who the
20 * reader is leaves it empty, which is one reader rather than none.
21 */
22export const PullRequestFilesViewedScope = Schema.Struct({
23 provider: SourceControlProviderKind,
24 host: Schema.String,
25 repository: Schema.String,
26 number: Schema.Int,
27 viewer: Schema.String,
28});
29export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type;
30
31/** A file this reader cleared, and what it was when they cleared it. */
32export const PullRequestFileViewedMark = Schema.Struct({
33 path: Schema.String,
34 /**
35 * The host's own name for that version of the file, opaque here. Empty where the host said it
36 * had none to give, which is an answer rather than a gap: a file with no version at the head is
37 * one the change request deletes. Null where the host could not say at all, which is no baseline
38 * rather than an empty one, and such a mark stays cleared until a press replaces it.
39 *
40 * This null is the only one this environment invents; the other two are in
41 * `docs/internals/pull-request-file-revisions.md`.
42 */
43 revision: Schema.NullOr(Schema.String),
44});
45export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type;
46
47export interface SetPullRequestFilesViewedInput extends PullRequestFilesViewedScope {
48 readonly files: ReadonlyArray<PullRequestFileViewedMark & { readonly viewed: boolean }>;
49 /** When the presses landed, as an ISO instant. */
50 readonly viewedAt: string;
51}
52
53/**
54 * How many marks one read of this store carries. Every one of them is a path held in a set and a
55 * map for as long as the caller holds the read, so an unbounded read of a change request with
56 * thousands of marks in it is paid for again per scope the caller is holding. Matched to what the
57 * GitHub reader walks in one go, past what anyone reviews in a sitting.
58 */
59export const MAX_FILES_VIEWED_ROWS = 500;
60
61/** The marks for one scope, and whether the store had more of them than it carried. */
62export interface PullRequestFilesViewedPage {
63 readonly files: ReadonlyArray<PullRequestFileViewedMark>;
64 readonly truncated: boolean;
65}
66
67/**
68 * The marks this environment keeps for hosts that keep none of their own.
69 *
70 * Only cleared files are rows. Unticking deletes rather than writing a "not viewed" row, so the
71 * table holds what a reader has done and not what they have merely scrolled past.
72 */
73export class PullRequestFilesViewedRepository extends Context.Service<
74 PullRequestFilesViewedRepository,
75 {
76 readonly list: (
77 input: PullRequestFilesViewedScope,
78 ) => Effect.Effect<PullRequestFilesViewedPage, PullRequestFilesViewedRepositoryError>;
79 readonly set: (
80 input: SetPullRequestFilesViewedInput,
81 ) => Effect.Effect<void, PullRequestFilesViewedRepositoryError>;
82 }
83>()("t3/persistence/PullRequestFilesViewed/PullRequestFilesViewedRepository") {}
84
85function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
86 return (cause: unknown): PullRequestFilesViewedRepositoryError =>
87 Schema.isSchemaError(cause)
88 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause)
89 : new PersistenceSqlError({ operation: sqlOperation, cause });
90}
91
92const make = Effect.gen(function* () {
93 const sql = yield* SqlClient.SqlClient;
94
95 const listRows = SqlSchema.findAll({
96 Request: PullRequestFilesViewedScope,
97 Result: PullRequestFileViewedMark,
98 execute: ({ provider, host, repository, number, viewer }) =>
99 sql`
100 SELECT
101 path AS "path",
102 revision AS "revision"
103 FROM pull_request_files_viewed
104 WHERE provider = ${provider}
105 AND host = ${host}
106 AND repository = ${repository}
107 AND number = ${number}
108 AND viewer = ${viewer}
109 ORDER BY path
110 LIMIT ${MAX_FILES_VIEWED_ROWS + 1}
111 `,
112 });
113
114 return PullRequestFilesViewedRepository.of({
115 // Ordered by path and read one row past the ceiling, so the same marks come back on every
116 // read rather than a window that shuffles, and having more than were carried is known rather
117 // than guessed at from a full page.
118 list: (input) =>
119 listRows(input).pipe(
120 Effect.map((rows) => ({
121 files: rows.slice(0, MAX_FILES_VIEWED_ROWS),
122 truncated: rows.length > MAX_FILES_VIEWED_ROWS,
123 })),
124 Effect.mapError(toSqlOrDecodeError("listPullRequestFilesViewed", "PullRequestFileViewed")),
125 ),
126
127 // One statement per file rather than one for the batch: the batch is what a reader ticked in
128 // the last few hundred milliseconds, so it is a handful of rows on a local database, and a
129 // mixed batch of clears and un-clears has no single statement anyway.
130 set: (input) =>
131 // One transaction for the batch. A press is a handful of files, and a failure part way
132 // through would otherwise leave some of them cleared and the rest not, which the reader
133 // sees on the next read as marks they never made.
134 sql
135 .withTransaction(
136 Effect.forEach(
137 input.files,
138 (file) =>
139 file.viewed
140 ? sql`
141 INSERT INTO pull_request_files_viewed (
142 provider,
143 host,
144 repository,
145 number,
146 viewer,
147 path,
148 revision,
149 viewed_at
150 )
151 VALUES (
152 ${input.provider},
153 ${input.host},
154 ${input.repository},
155 ${input.number},
156 ${input.viewer},
157 ${file.path},
158 ${file.revision},
159 ${input.viewedAt}
160 )
161 ON CONFLICT (provider, host, repository, number, viewer, path)
162 DO UPDATE SET revision = excluded.revision, viewed_at = excluded.viewed_at
163 `
164 : sql`
165 DELETE FROM pull_request_files_viewed
166 WHERE provider = ${input.provider}
167 AND host = ${input.host}
168 AND repository = ${input.repository}
169 AND number = ${input.number}
170 AND viewer = ${input.viewer}
171 AND path = ${file.path}
172 `,
173 { discard: true },
174 ),
175 )
176 .pipe(
177 Effect.mapError(
178 (cause) => new PersistenceSqlError({ operation: "setPullRequestFilesViewed", cause }),
179 ),
180 ),
181 });
182});
183
184export const layer = Layer.effect(PullRequestFilesViewedRepository, make);
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+836-18
......@@ -29,6 +29,45 @@ function output(stdout: string) {
2929 };
3030}
3131
32/** What VcsProcess allows a read that asked for no ceiling of its own. */
33const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
34
35/**
36 * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts
37 * it at the process default when the caller asked for none. A read whose response is larger than
38 * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling.
39 */
40function outputWithin(maxOutputBytes: number | undefined, response: string) {
41 const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES;
42 return ceiling >= Buffer.byteLength(response)
43 ? output(response)
44 : { ...output(response.slice(0, ceiling)), stdoutTruncated: true };
45}
46
47/** A fixture's own shape, spelled the way `az` would answer with it. */
48const json = (value: Record<string, unknown>) => JSON.stringify(value);
49
50const pullRequestRow = {
51 pullRequestId: 42,
52 title: "Add the page",
53 status: "active",
54 sourceRefName: "refs/heads/feat/page",
55 targetRefName: "refs/heads/main",
56 creationDate: "2026-07-01T00:00:00Z",
57 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
58 repository: { name: "web", project: { name: "platform" } },
59};
60
61const oneIteration = {
62 value: [
63 {
64 id: 1,
65 sourceRefCommit: { commitId: "a".repeat(40) },
66 commonRefCommit: { commitId: "b".repeat(40) },
67 },
68 ],
69};
70
3271function pullRequestRows(
3372 count: number,
3473 firstNumber: number,
......@@ -56,6 +95,92 @@ function argsOfCall(index: number): ReadonlyArray<string> {
5695 return call[0].args;
5796}
5897
98/** The output ceiling the nth az invocation asked for, if it asked for one at all. */
99function maxOutputBytesOfCall(index: number) {
100 const call = mockedExecute.mock.calls[index];
101 assert.isDefined(call);
102 return call[0].maxOutputBytes;
103}
104
105/** A page of change entries the size Azure really answers with, url and object ids and all. */
106function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> {
107 const commit = "c".repeat(40);
108 return Array.from({ length: count }, (_, index) => {
109 const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`;
110 return {
111 changeType: "edit",
112 item: {
113 path,
114 objectId: "a".repeat(40),
115 originalObjectId: "b".repeat(40),
116 commitId: commit,
117 gitObjectType: "blob",
118 url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`,
119 },
120 };
121 });
122}
123
124/** An Azure identity, which rides along with every comment and every push Azure answers with. */
125function identity(name: string) {
126 const id = "6f9c9b7f-0000-0000-0000-000000000000";
127 return {
128 displayName: name,
129 id,
130 uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`,
131 descriptor: `aad.${"z".repeat(52)}`,
132 imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`,
133 url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`,
134 _links: {
135 avatar: {
136 href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`,
137 },
138 },
139 };
140}
141
142/** A review's threads the shape Azure answers with, system threads and identities and all. */
143function threadRows(count: number): ReadonlyArray<Record<string, unknown>> {
144 return Array.from({ length: count }, (_, index) => ({
145 id: index + 1,
146 publishedDate: "2026-07-02T00:00:00Z",
147 lastUpdatedDate: "2026-07-02T00:00:00Z",
148 status: "active",
149 threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` },
150 identities: { 1: identity("Reviewer One") },
151 isDeleted: false,
152 comments: [
153 {
154 id: 1,
155 parentCommentId: 0,
156 author: identity("Reviewer One"),
157 content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`,
158 publishedDate: "2026-07-02T00:00:00Z",
159 lastUpdatedDate: "2026-07-02T00:00:00Z",
160 commentType: "text",
161 usersLiked: [],
162 },
163 ],
164 }));
165}
166
167/** A review's iterations the shape Azure answers with, one per push. */
168function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> {
169 return Array.from({ length: count }, (_, index) => ({
170 id: index + 1,
171 description: `Pushed ${index} commits`,
172 author: identity("Author One"),
173 createdDate: "2026-07-02T00:00:00Z",
174 updatedDate: "2026-07-02T00:00:00Z",
175 sourceRefCommit: { commitId: `${index}`.padStart(40, "a") },
176 targetRefCommit: { commitId: "b".repeat(40) },
177 commonRefCommit: { commitId: "c".repeat(40) },
178 hasMultipleCommits: true,
179 reason: "push",
180 push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") },
181 }));
182}
183
59184afterEach(() => {
60185 mockedExecute.mockReset();
61186});
......@@ -107,20 +232,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
107232 const response = JSON.stringify(rows);
108233 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
109234
110 mockedExecute.mockImplementationOnce((input) => {
111 const maxOutputBytes =
112 "maxOutputBytes" in input && typeof input.maxOutputBytes === "number"
113 ? input.maxOutputBytes
114 : 1_000_000;
115 return Effect.succeed(
116 maxOutputBytes >= Buffer.byteLength(response)
117 ? output(response)
118 : {
119 ...output(response.slice(0, maxOutputBytes)),
120 stdoutTruncated: true,
121 },
122 );
123 });
235 mockedExecute.mockImplementationOnce((input) =>
236 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
237 );
124238 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
125239
126240 const batch = yield* cli.listPullRequests({
......@@ -516,6 +630,590 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
516630 }),
517631 );
518632
633 it.effect("names the head's blob as what a cleared file was cleared at", () =>
634 Effect.gen(function* () {
635 mockedExecute
636 .mockReturnValueOnce(
637 Effect.succeed(
638 output(
639 // @effect-diagnostics-next-line preferSchemaOverJson:off
640 JSON.stringify({
641 pullRequestId: 42,
642 title: "Add the page",
643 status: "active",
644 sourceRefName: "refs/heads/feat/page",
645 targetRefName: "refs/heads/main",
646 creationDate: "2026-07-01T00:00:00Z",
647 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
648 repository: { name: "web", project: { name: "platform" } },
649 }),
650 ),
651 ),
652 )
653 .mockReturnValueOnce(
654 Effect.succeed(
655 output(
656 // @effect-diagnostics-next-line preferSchemaOverJson:off
657 JSON.stringify({
658 value: [
659 {
660 id: 1,
661 sourceRefCommit: { commitId: "a".repeat(40) },
662 commonRefCommit: { commitId: "b".repeat(40) },
663 },
664 {
665 id: 2,
666 sourceRefCommit: { commitId: "c".repeat(40) },
667 commonRefCommit: { commitId: "b".repeat(40) },
668 },
669 ],
670 }),
671 ),
672 ),
673 )
674 .mockReturnValueOnce(
675 Effect.succeed(
676 output(
677 // @effect-diagnostics-next-line preferSchemaOverJson:off
678 JSON.stringify({
679 changeEntries: [
680 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
681 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
682 ],
683 }),
684 ),
685 ),
686 );
687 const provider = yield* AzureDevOpsPullRequestProvider.make;
688
689 // Kept here rather than on Azure: its own record of what a reader has read is behind an
690 // undocumented endpoint, so the marks belong to this environment and need a revision of
691 // their own to tell a re-push from a file still as it was read.
692 assert.strictEqual(provider.capabilities.viewedFiles, "environment");
693 assert.isDefined(provider.getFileRevisions);
694 const answer = yield* provider.getFileRevisions({
695 cwd: "/w",
696 repository: "web",
697 host: "dev.azure.com",
698 number: 42,
699 paths: ["README.md"],
700 });
701
702 // The latest push, since an iteration's changes are reported against the merge base rather
703 // than against the push before it.
704 expect(argsOfCall(2)).toContain("iterationId=2");
705 // Only what was asked for. DEMO.md changed too, and nobody has marked it.
706 expect([...answer.revisions]).toEqual([["README.md", "8f80"]]);
707 }),
708 );
709
710 it.effect("reads where a pull request lives once, however often it is asked about", () =>
711 Effect.gen(function* () {
712 // A pull request cannot move repositories, and the marks would otherwise pay for a whole
713 // pull request read every time they checked whether a file had been pushed to.
714 const pullRequest = Effect.succeed(
715 output(
716 // @effect-diagnostics-next-line preferSchemaOverJson:off
717 JSON.stringify({
718 pullRequestId: 42,
719 title: "Add the page",
720 status: "active",
721 sourceRefName: "refs/heads/feat/page",
722 targetRefName: "refs/heads/main",
723 creationDate: "2026-07-01T00:00:00Z",
724 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
725 repository: { name: "web", project: { name: "platform" } },
726 }),
727 ),
728 );
729 const iterations = () =>
730 Effect.succeed(
731 output(
732 JSON.stringify({
733 value: [
734 {
735 id: 1,
736 sourceRefCommit: { commitId: "a".repeat(40) },
737 commonRefCommit: { commitId: "b".repeat(40) },
738 },
739 ],
740 }),
741 ),
742 );
743 const changes = () =>
744 Effect.succeed(
745 output(
746 JSON.stringify({
747 changeEntries: [
748 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
749 ],
750 }),
751 ),
752 );
753 mockedExecute
754 .mockReturnValueOnce(pullRequest)
755 .mockReturnValueOnce(iterations())
756 .mockReturnValueOnce(changes())
757 .mockReturnValueOnce(iterations())
758 .mockReturnValueOnce(changes());
759 const provider = yield* AzureDevOpsPullRequestProvider.make;
760 const read = provider.getFileRevisions;
761 assert.isDefined(read);
762 const ask = () =>
763 read({
764 cwd: "/w",
765 repository: "web",
766 host: "dev.azure.com",
767 number: 42,
768 paths: ["README.md"],
769 });
770
771 yield* ask();
772 const again = yield* ask();
773
774 assert.strictEqual(mockedExecute.mock.calls.length, 5);
775 // The second read goes straight to the pushes, and still answers with the head's blob.
776 expect(argsOfCall(3)).toContain("pullRequestIterations");
777 expect([...again.revisions]).toEqual([["README.md", "8f80"]]);
778 }),
779 );
780
781 it.effect(
782 "answers for a marked file the pull request no longer changes as the empty version",
783 () =>
784 Effect.gen(function* () {
785 // Which is what was stored for it when it was ticked with nothing on the head, so a file
786 // the pull request deletes is cleared once and stays cleared.
787 mockedExecute
788 .mockReturnValueOnce(
789 Effect.succeed(
790 output(
791 json({
792 pullRequestId: 42,
793 title: "Add the page",
794 status: "active",
795 sourceRefName: "refs/heads/feat/page",
796 targetRefName: "refs/heads/main",
797 creationDate: "2026-07-01T00:00:00Z",
798 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
799 repository: { name: "web", project: { name: "platform" } },
800 }),
801 ),
802 ),
803 )
804 .mockReturnValueOnce(
805 Effect.succeed(
806 output(
807 json({
808 value: [
809 {
810 id: 1,
811 sourceRefCommit: { commitId: "a".repeat(40) },
812 commonRefCommit: { commitId: "b".repeat(40) },
813 },
814 ],
815 }),
816 ),
817 ),
818 )
819 .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}')));
820 const provider = yield* AzureDevOpsPullRequestProvider.make;
821 assert.isDefined(provider.getFileRevisions);
822
823 const answer = yield* provider.getFileRevisions({
824 cwd: "/w",
825 repository: "web",
826 host: "dev.azure.com",
827 number: 42,
828 paths: ["GONE.md"],
829 });
830
831 expect([...answer.revisions]).toEqual([["GONE.md", ""]]);
832 }),
833 );
834
835 it.effect("says nothing about the files past the end of a change it gave up following", () =>
836 Effect.gen(function* () {
837 // Every page is an `az` process of its own, so a change past the ceiling stops being
838 // followed. A path nobody looked at must not be answered for as deleted.
839 const entries = (from: number, count: number) =>
840 Array.from({ length: count }, (_, index) => ({
841 changeType: "edit",
842 item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` },
843 }));
844 mockedExecute
845 .mockReturnValueOnce(
846 Effect.succeed(
847 output(
848 json({
849 pullRequestId: 42,
850 title: "Add the page",
851 status: "active",
852 sourceRefName: "refs/heads/feat/page",
853 targetRefName: "refs/heads/main",
854 creationDate: "2026-07-01T00:00:00Z",
855 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
856 repository: { name: "web", project: { name: "platform" } },
857 }),
858 ),
859 ),
860 )
861 .mockReturnValueOnce(
862 Effect.succeed(
863 output(
864 json({
865 value: [
866 {
867 id: 1,
868 sourceRefCommit: { commitId: "a".repeat(40) },
869 commonRefCommit: { commitId: "b".repeat(40) },
870 },
871 ],
872 }),
873 ),
874 ),
875 )
876 .mockReturnValueOnce(
877 Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))),
878 )
879 .mockReturnValueOnce(
880 Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))),
881 );
882 const provider = yield* AzureDevOpsPullRequestProvider.make;
883 assert.isDefined(provider.getFileRevisions);
884
885 const answer = yield* provider.getFileRevisions({
886 cwd: "/w",
887 repository: "web",
888 host: "dev.azure.com",
889 number: 42,
890 paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"],
891 });
892
893 // The second page picks up where the first said it ended.
894 expect(argsOfCall(3)).toContain("$skip=5000");
895 expect([...answer.revisions]).toEqual([
896 ["src/f1.ts", "blob-1"],
897 ["src/f5001.ts", "blob-5001"],
898 ]);
899 }),
900 );
901
902 it.effect("stops following pages by what Azure counts, not by what survives decoding", () =>
903 Effect.gen(function* () {
904 mockedExecute
905 .mockReturnValueOnce(
906 Effect.succeed(
907 output(
908 json({
909 pullRequestId: 42,
910 title: "Add the page",
911 status: "active",
912 sourceRefName: "refs/heads/feat/page",
913 targetRefName: "refs/heads/main",
914 creationDate: "2026-07-01T00:00:00Z",
915 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
916 repository: { name: "web", project: { name: "platform" } },
917 }),
918 ),
919 ),
920 )
921 .mockReturnValueOnce(
922 Effect.succeed(
923 output(
924 json({
925 value: [
926 {
927 id: 1,
928 sourceRefCommit: { commitId: "a".repeat(40) },
929 commonRefCommit: { commitId: "b".repeat(40) },
930 },
931 ],
932 }),
933 ),
934 ),
935 )
936 // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted
937 // in files would never be reached however long the walk went on.
938 .mockImplementation((command) => {
939 const skip = command.args.find((arg) => arg.startsWith("$skip="));
940 const from = Number(skip?.slice("$skip=".length) ?? 0);
941 return Effect.succeed(
942 output(
943 json({
944 changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }],
945 nextSkip: from + 2_000,
946 }),
947 ),
948 );
949 });
950 const provider = yield* AzureDevOpsPullRequestProvider.make;
951 assert.isDefined(provider.getFileRevisions);
952
953 const answer = yield* provider.getFileRevisions({
954 cwd: "/w",
955 repository: "web",
956 host: "dev.azure.com",
957 number: 42,
958 paths: ["src/page.ts"],
959 });
960
961 // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset
962 // rather than spending an `az` process a page for as long as Azure keeps paging.
963 assert.strictEqual(mockedExecute.mock.calls.length, 7);
964 // And it read part of a change, so it says nothing about the file it never saw.
965 assert.strictEqual(answer.revisions.size, 0);
966 }),
967 );
968
969 it.effect("leaves one file the host would not hand over listed without its hunks", () =>
970 Effect.gen(function* () {
971 mockedExecute
972 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
973 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
974 .mockReturnValueOnce(
975 Effect.succeed(
976 output(
977 json({
978 changeEntries: [
979 { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } },
980 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
981 ],
982 }),
983 ),
984 ),
985 )
986 .mockReturnValueOnce(
987 Effect.fail(
988 new AzureDevOpsCli.AzureDevOpsCommandFailedError({
989 operation: "execute",
990 command: "az",
991 cwd: "/w",
992 argumentCount: 1,
993 cause: "the blob is past what the route will carry",
994 }),
995 ),
996 )
997 .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" }))));
998 const provider = yield* AzureDevOpsPullRequestProvider.make;
999
1000 const slice = yield* provider.getDiff({
1001 cwd: "/w",
1002 repository: "web",
1003 host: "dev.azure.com",
1004 number: 42,
1005 });
1006
1007 assert.isTrue(slice.truncated);
1008 expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin");
1009 // And the file behind it still renders, which is the point of giving up on one file.
1010 expect(slice.patch).toContain("+hello");
1011 }),
1012 );
1013
1014 it.effect("fails the whole read when it is the connection that would not answer", () =>
1015 Effect.gen(function* () {
1016 // A rate limit is not this file's problem, and answering with a change full of files listed
1017 // without their hunks would read as a change nobody can see rather than as a host to wait
1018 // for.
1019 mockedExecute
1020 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
1021 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
1022 .mockReturnValueOnce(
1023 Effect.succeed(
1024 output(
1025 json({
1026 changeEntries: [
1027 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
1028 ],
1029 }),
1030 ),
1031 ),
1032 )
1033 .mockReturnValueOnce(
1034 Effect.fail(
1035 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
1036 operation: "execute",
1037 command: "az",
1038 cwd: "/w",
1039 argumentCount: 1,
1040 cause: "429",
1041 }),
1042 ),
1043 );
1044 const provider = yield* AzureDevOpsPullRequestProvider.make;
1045
1046 const error = yield* Effect.flip(
1047 provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }),
1048 );
1049
1050 assert.strictEqual(error.reason, "rate-limited");
1051 }),
1052 );
1053
1054 it.effect("takes Azure's own word on a file it will not spell out", () =>
1055 Effect.gen(function* () {
1056 mockedExecute
1057 .mockReturnValueOnce(
1058 Effect.succeed(
1059 output(
1060 json({
1061 pullRequestId: 42,
1062 title: "Add the page",
1063 status: "active",
1064 sourceRefName: "refs/heads/feat/page",
1065 targetRefName: "refs/heads/main",
1066 creationDate: "2026-07-01T00:00:00Z",
1067 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1068 repository: { name: "web", project: { name: "platform" } },
1069 }),
1070 ),
1071 ),
1072 )
1073 .mockReturnValueOnce(
1074 Effect.succeed(
1075 output(
1076 json({
1077 value: [
1078 {
1079 id: 1,
1080 sourceRefCommit: { commitId: "a".repeat(40) },
1081 commonRefCommit: { commitId: "b".repeat(40) },
1082 },
1083 ],
1084 }),
1085 ),
1086 ),
1087 )
1088 .mockReturnValueOnce(
1089 Effect.succeed(
1090 output(
1091 json({
1092 changeEntries: [
1093 { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } },
1094 ],
1095 }),
1096 ),
1097 ),
1098 )
1099 .mockReturnValueOnce(
1100 Effect.succeed(
1101 output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })),
1102 ),
1103 );
1104 const provider = yield* AzureDevOpsPullRequestProvider.make;
1105
1106 const slice = yield* provider.getDiff({
1107 cwd: "/w",
1108 repository: "web",
1109 host: "dev.azure.com",
1110 number: 42,
1111 });
1112
1113 // Azure leaves the metadata out unless it is asked for, and without it every file reads as
1114 // text however it was stored.
1115 expect(argsOfCall(3)).toContain("includeContentMetadata=true");
1116 expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ");
1117 assert.isTrue(slice.truncated);
1118 }),
1119 );
1120
1121 it.effect("stops following pages when one of them does not move the cursor on", () =>
1122 Effect.gen(function* () {
1123 mockedExecute
1124 .mockReturnValueOnce(
1125 Effect.succeed(
1126 output(
1127 json({
1128 pullRequestId: 42,
1129 title: "Add the page",
1130 status: "active",
1131 sourceRefName: "refs/heads/feat/page",
1132 targetRefName: "refs/heads/main",
1133 creationDate: "2026-07-01T00:00:00Z",
1134 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1135 repository: { name: "web", project: { name: "platform" } },
1136 }),
1137 ),
1138 ),
1139 )
1140 .mockReturnValueOnce(
1141 Effect.succeed(
1142 output(
1143 json({
1144 value: [
1145 {
1146 id: 1,
1147 sourceRefCommit: { commitId: "a".repeat(40) },
1148 commonRefCommit: { commitId: "b".repeat(40) },
1149 },
1150 ],
1151 }),
1152 ),
1153 ),
1154 )
1155 .mockReturnValueOnce(
1156 Effect.succeed(
1157 output(
1158 json({
1159 changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }],
1160 nextSkip: 2_000,
1161 }),
1162 ),
1163 ),
1164 )
1165 .mockReturnValueOnce(
1166 Effect.succeed(
1167 output(
1168 json({
1169 changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }],
1170 nextSkip: 2_000,
1171 }),
1172 ),
1173 ),
1174 );
1175 const provider = yield* AzureDevOpsPullRequestProvider.make;
1176 assert.isDefined(provider.getFileRevisions);
1177
1178 const answer = yield* provider.getFileRevisions({
1179 cwd: "/w",
1180 repository: "web",
1181 host: "dev.azure.com",
1182 number: 42,
1183 paths: ["a.ts", "b.ts", "unlisted.ts"],
1184 });
1185
1186 // Four reads and no more: a page pointing at where it already is would be read forever.
1187 assert.strictEqual(mockedExecute.mock.calls.length, 4);
1188 // And what was read is not the whole change, so the file nobody listed is left unanswered
1189 // rather than reported as gone from the change request.
1190 expect([...answer.revisions]).toEqual([
1191 ["a.ts", "8f80"],
1192 ["b.ts", "0ca4"],
1193 ]);
1194 }),
1195 );
1196
1197 it.effect("asks Azure nothing when no file has been ticked off", () =>
1198 Effect.gen(function* () {
1199 mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}')));
1200 const provider = yield* AzureDevOpsPullRequestProvider.make;
1201 assert.isDefined(provider.getFileRevisions);
1202
1203 const answer = yield* provider.getFileRevisions({
1204 cwd: "/w",
1205 repository: "web",
1206 host: "dev.azure.com",
1207 number: 42,
1208 paths: [],
1209 });
1210
1211 expect(answer.revisions.size).toBe(0);
1212 // Nothing was marked, so Azure was not asked at all.
1213 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1214 }),
1215 );
1216
5191217 it.effect("reads the conversation through the REST API, pinned to a version", () =>
5201218 Effect.gen(function* () {
5211219 mockedExecute.mockReturnValueOnce(
......@@ -539,14 +1237,134 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
5391237
5401238 const comments = yield* cli.listThreads({
5411239 cwd: "/w",
542 threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads",
1240 location: { project: "platform", repository: "web" },
1241 number: 42,
5431242 });
5441243
5451244 assert.strictEqual(comments.length, 1);
546 expect(argsOfCall(0)).toContain("rest");
547 expect(argsOfCall(0)).toContain(
548 "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1",
1245 // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension
1246 // does, and `az rest` mints its own token against whichever tenant `az` defaults to.
1247 expect(argsOfCall(0)).toContain("invoke");
1248 expect(argsOfCall(0)).toContain("pullRequestThreads");
1249 expect(argsOfCall(0)).toContain("project=platform");
1250 expect(argsOfCall(0)).toContain("repositoryId=web");
1251 expect(argsOfCall(0)).toContain("pullRequestId=42");
1252 // A review's threads grow with how long it ran, and this route does not page, so the read
1253 // asks for more than the process default rather than taking whatever it is given.
1254 expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1255 }),
1256 );
1257
1258 it.effect("reads a long review's threads, which are past the default output limit", () =>
1259 Effect.gen(function* () {
1260 const response = json({ value: threadRows(800) });
1261 // Azure opens a thread per vote and per ref update beside the ones people wrote, and every
1262 // comment carries a full identity, so a review argued over for weeks outgrows the default.
1263 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1264 mockedExecute.mockImplementationOnce((input) =>
1265 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
5491266 );
1267 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1268
1269 const comments = yield* cli.listThreads({
1270 cwd: "/w",
1271 location: { project: "platform", repository: "web" },
1272 number: 42,
1273 });
1274
1275 assert.strictEqual(comments.length, 800);
1276 }),
1277 );
1278
1279 it.effect("reads a long review's iterations, which are past the default output limit", () =>
1280 Effect.gen(function* () {
1281 const response = json({ value: iterationRows(1_200) });
1282 // This route does not page, so the whole history arrives at once. Cut at the default it is
1283 // JSON stopping mid-string, and every diff and file revision read on this host fails with
1284 // it, since each of them starts by asking which iteration is the latest.
1285 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1286 mockedExecute.mockImplementationOnce((input) =>
1287 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1288 );
1289 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1290
1291 const iterations = yield* cli.listIterations({
1292 cwd: "/w",
1293 location: { project: "platform", repository: "web" },
1294 number: 42,
1295 });
1296
1297 assert.strictEqual(iterations.length, 1_200);
1298 assert.strictEqual(iterations.at(-1)?.id, 1_200);
1299 }),
1300 );
1301
1302 it.effect("reads a full page of change entries, which is past the default output limit", () =>
1303 Effect.gen(function* () {
1304 const response = json({ changeEntries: changeEntries(2_000) });
1305 // Azure's own maximum for this route, and every entry carries a path, a url and three
1306 // object ids, so an ordinary page of a large change already outgrows the default.
1307 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1308 mockedExecute.mockImplementationOnce((input) =>
1309 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1310 );
1311 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1312
1313 const page = yield* cli.listIterationChanges({
1314 cwd: "/w",
1315 location: { project: "platform", repository: "web" },
1316 number: 42,
1317 iterationId: 1,
1318 });
1319
1320 // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly
1321 // ordinary page would be reported as a host answering with nonsense.
1322 assert.strictEqual(page.changes.length, 2_000);
1323 assert.isFalse(page.truncated);
1324 }),
1325 );
1326
1327 it.effect("reads a file whose JSON envelope is past the default output limit", () =>
1328 Effect.gen(function* () {
1329 // Under a megabyte as bytes on the host, so this is a file the other hosts hand over.
1330 const file = "const value = 1;\n".repeat(57_000);
1331 const response = json({ content: file });
1332 expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1333 // And past it once Azure wraps it, because there is no route here that serves the bytes.
1334 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1335 mockedExecute.mockImplementationOnce((input) =>
1336 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1337 );
1338 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1339
1340 const item = yield* cli.readItemContent({
1341 cwd: "/w",
1342 location: { project: "platform", repository: "web" },
1343 path: "src/generated/schema.ts",
1344 commit: "a".repeat(40),
1345 });
1346
1347 assert.strictEqual(item.contents, file);
1348 assert.isFalse(item.isBinary);
1349 }),
1350 );
1351
1352 it.effect("asks for a file by Azure's own spelling of its path", () =>
1353 Effect.gen(function* () {
1354 mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" }))));
1355 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1356
1357 // The path carried around here has had Azure's leading slash taken off so it matches the
1358 // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes
1359 // back on the way out rather than being sent as the shorter name.
1360 yield* cli.readItemContent({
1361 cwd: "/w",
1362 location: { project: "platform", repository: "web" },
1363 path: "src/app.ts",
1364 commit: "a".repeat(40),
1365 });
1366
1367 expect(argsOfCall(0)).toContain("path=/src/app.ts");
5501368 }),
5511369 );
5521370
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+222-23
......@@ -13,11 +13,18 @@ import type {
1313
1414import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
1515import {
16 decodeItemContentJson,
17 decodeIterationChangesJson,
18 decodeIterationsJson,
1619 decodePullRequestJson,
1720 decodePullRequestListJson,
1821 decodeThreadsJson,
1922 decodeViewerJson,
23 type AzureDevOpsChangeEntry,
24 type AzureDevOpsItemContent,
25 type AzureDevOpsIteration,
2026 type AzureDevOpsPullRequest,
27 type AzureDevOpsRepositoryLocation,
2128} from "./azureDevOpsPullRequestJson.ts";
2229import type { ProviderListCursor } from "./PullRequestProvider.ts";
2330
......@@ -112,6 +119,44 @@ export type AzureDevOpsPullRequestCliError =
112119/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
113120const REST_API_VERSION = "7.1";
114121const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
122/**
123 * A full page of change entries is two thousand files, each carrying its path, its url and
124 * several object ids, which is past the megabyte a read is given by default. Output cut at that
125 * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would
126 * report itself as a host returning nonsense rather than as the ordinary page it is.
127 */
128const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
129/**
130 * Four times the megabyte of file the other hosts hand over, because Azure has no route that
131 * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and
132 * base64 if it is not, and both are larger than the file they carry.
133 */
134const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
135/**
136 * What a review's own history is given. Neither route pages, so each answers with the whole of it
137 * at once and grows with how long the review ran rather than with how large the change is. Cut at
138 * the default, both arrive as JSON that stops mid-string, and a long review would report its host
139 * as answering with nonsense.
140 */
141const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
142
143/** Azure's own ceiling for one page of an iteration's changes. */
144const CHANGE_ENTRIES_PER_PAGE = 2000;
145
146/**
147 * Where following the pages stops, counted in the entries Azure was asked to skip rather than in
148 * the files that survived decoding: a page can be entirely folders and other entries a review has
149 * nothing to show for, and bounding on what was kept would follow such a change forever. Every
150 * page is an `az` process of its own, so the read gives up and reports itself incomplete rather
151 * than presenting five pages as the whole change.
152 */
153const MAX_CHANGE_ENTRIES = 10_000;
154
155/** What an iteration changed, and whether following its pages reached the end of it. */
156export interface AzureDevOpsIterationChanges {
157 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
158 readonly truncated: boolean;
159}
115160
116161export class AzureDevOpsPullRequestCli extends Context.Service<
117162 AzureDevOpsPullRequestCli,
......@@ -150,9 +195,42 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
150195 /** Threads are not reachable through `az repos pr`, so they come from the REST API. */
151196 readonly listThreads: (input: {
152197 readonly cwd: string;
153 readonly threadsUrl: string;
198 readonly location: AzureDevOpsRepositoryLocation;
199 readonly number: number;
154200 }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>;
155201
202 /**
203 * The pushes a pull request has had, oldest first. Azure hangs the changed files off an
204 * iteration rather than off the pull request, so reading a diff starts here.
205 */
206 readonly listIterations: (input: {
207 readonly cwd: string;
208 readonly location: AzureDevOpsRepositoryLocation;
209 readonly number: number;
210 }) => Effect.Effect<ReadonlyArray<AzureDevOpsIteration>, AzureDevOpsPullRequestCliError>;
211
212 /**
213 * What one iteration changed, against the merge base rather than against the previous push,
214 * which is the whole of the pull request rather than the latest slice of it.
215 */
216 readonly listIterationChanges: (input: {
217 readonly cwd: string;
218 readonly location: AzureDevOpsRepositoryLocation;
219 readonly number: number;
220 readonly iterationId: number;
221 }) => Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError>;
222
223 /**
224 * One file's text at one commit. Azure has no diff route that carries content, so both sides
225 * of every changed file are read this way and the patch is made from them here.
226 */
227 readonly readItemContent: (input: {
228 readonly cwd: string;
229 readonly location: AzureDevOpsRepositoryLocation;
230 readonly path: string;
231 readonly commit: string;
232 }) => Effect.Effect<AzureDevOpsItemContent, AzureDevOpsPullRequestCliError>;
233
156234 readonly runPullRequestAction: (input: {
157235 readonly cwd: string;
158236 readonly number: number;
......@@ -281,6 +359,77 @@ export const make = Effect.gen(function* () {
281359 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
282360 });
283361
362 /**
363 * A REST route reached through `az devops invoke`, which addresses it by area, resource and
364 * route parameters rather than by URL. Used in place of `az rest` because it signs in the way
365 * the azure-devops extension does: `az rest` mints its own token against the tenant `az`
366 * defaults to, and an organisation in any other tenant answers that with a sign-in page, which
367 * arrives here as unreadable output rather than as a failure.
368 */
369 const invoke = <A>(input: {
370 readonly cwd: string;
371 readonly operation: string;
372 readonly resource: string;
373 readonly routeParameters: ReadonlyArray<string>;
374 readonly queryParameters?: ReadonlyArray<string>;
375 readonly maxOutputBytes?: number;
376 readonly decode: (raw: string) => Result.Result<A, unknown>;
377 }): Effect.Effect<A, AzureDevOpsPullRequestCliError> =>
378 executeJson({
379 cwd: input.cwd,
380 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
381 args: [
382 "devops",
383 "invoke",
384 ...detectArgs,
385 "--area",
386 "git",
387 "--resource",
388 input.resource,
389 "--api-version",
390 REST_API_VERSION,
391 "--route-parameters",
392 ...input.routeParameters,
393 ...(input.queryParameters === undefined
394 ? []
395 : ["--query-parameters", ...input.queryParameters]),
396 ],
397 }).pipe(
398 Effect.flatMap((result) => {
399 const decoded = input.decode(result.stdout.trim());
400 return Result.isSuccess(decoded)
401 ? Effect.succeed(decoded.success)
402 : Effect.fail(
403 new AzureDevOpsPullRequestReadError({
404 command: "az",
405 cwd: input.cwd,
406 operation: input.operation,
407 cause: decoded.failure,
408 }),
409 );
410 }),
411 );
412
413 /**
414 * Azure names its own items with a leading slash, which the repository paths carried around
415 * here have had taken off so they match the patch and the viewed mark. Put it back on the way
416 * out, because the items route is documented in Azure's own spelling.
417 */
418 const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`);
419
420 const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray<string> => [
421 `project=${location.project}`,
422 `repositoryId=${location.repository}`,
423 ];
424
425 const pullRequestRoute = (input: {
426 readonly location: AzureDevOpsRepositoryLocation;
427 readonly number: number;
428 }): ReadonlyArray<string> => [
429 ...repositoryRoute(input.location),
430 `pullRequestId=${input.number}`,
431 ];
432
284433 /**
285434 * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
286435 * 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* () {
448597 ),
449598
450599 listThreads: (input) =>
451 executeJson({
600 invoke({
452601 cwd: input.cwd,
453 args: [
454 "rest",
455 "--method",
456 "get",
457 "--url",
458 `${input.threadsUrl}?api-version=${REST_API_VERSION}`,
602 operation: "listThreads",
603 resource: "pullRequestThreads",
604 routeParameters: pullRequestRoute(input),
605 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
606 decode: decodeThreadsJson,
607 }),
608
609 listIterations: (input) =>
610 invoke({
611 cwd: input.cwd,
612 operation: "listIterations",
613 resource: "pullRequestIterations",
614 routeParameters: pullRequestRoute(input),
615 maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES,
616 decode: decodeIterationsJson,
617 }),
618
619 listIterationChanges: (input) => {
620 const page = (skip: number) =>
621 invoke({
622 cwd: input.cwd,
623 operation: "listIterationChanges",
624 resource: "pullRequestIterationChanges",
625 routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`],
626 // Azure pages this route at 1000 entries by default; this is its own maximum per page,
627 // and it names where the next page starts rather than answering with the whole change.
628 queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`],
629 maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES,
630 decode: decodeIterationChangesJson,
631 });
632 const from = (
633 skip: number,
634 collected: ReadonlyArray<AzureDevOpsChangeEntry>,
635 ): Effect.Effect<AzureDevOpsIterationChanges, AzureDevOpsPullRequestCliError> =>
636 page(skip).pipe(
637 Effect.flatMap((answer) => {
638 const changes = [...collected, ...answer.changes];
639 // The last page names no page after it, and only that is the end of the change.
640 if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false });
641 // A page pointing at where the read already is would be followed forever, and one
642 // past the ceiling is a change nobody will read to the end of. Both stop the read
643 // and both say so, rather than presenting part of a change as the whole of it.
644 return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES
645 ? Effect.succeed({ changes, truncated: true })
646 : from(answer.nextSkip, changes);
647 }),
648 );
649 return from(0, []);
650 },
651
652 readItemContent: (input) =>
653 invoke({
654 cwd: input.cwd,
655 operation: "readItemContent",
656 resource: "items",
657 routeParameters: repositoryRoute(input.location),
658 queryParameters: [
659 `path=${toItemPath(input.path)}`,
660 "versionDescriptor.versionType=commit",
661 `versionDescriptor.version=${input.commit}`,
662 "includeContent=true",
663 // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own
664 // word on whether the file is binary, which is the only reliable one, since a binary
665 // file arrives encoded rather than as the bytes it is on the host.
666 "includeContentMetadata=true",
667 // Without this Azure answers with the file's own bytes rather than with a JSON
668 // envelope, and `az devops invoke` refuses anything it cannot parse as JSON.
669 "$format=json",
459670 ],
460 }).pipe(
461 Effect.flatMap((result) => {
462 const decoded = decodeThreadsJson(result.stdout.trim());
463 return Result.isSuccess(decoded)
464 ? Effect.succeed(decoded.success)
465 : Effect.fail(
466 new AzureDevOpsPullRequestReadError({
467 command: "az",
468 cwd: input.cwd,
469 operation: "listThreads",
470 cause: decoded.failure,
471 }),
472 );
473 }),
474 ),
671 maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES,
672 decode: decodeItemContentJson,
673 }),
475674
476675 setPullRequestReviewers: (input) =>
477676 input.reviewers.some((reviewer) => !isReviewerName(reviewer))
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts created+420
......@@ -0,0 +1,420 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 LOCATION_CACHE_CAPACITY,
8 make,
9 MAX_DIFF_SPAWNS,
10} from "./AzureDevOpsPullRequestProvider.ts";
11import {
12 byteLength,
13 MAX_DIFF_SLICE_BYTES,
14 MAX_DIFF_SLICE_FILES,
15 MAX_FILE_DIFF_EDITS,
16 parseAzureDevOpsDiffCursor,
17} from "./azureDevOpsDiff.ts";
18import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
19
20const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" };
21
22const PULL_REQUEST = {
23 number: 7,
24 title: "Pull request 7",
25 url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7",
26 author: null,
27 headBranch: "feat/page",
28 baseBranch: "main",
29 state: "open" as const,
30 isDraft: false,
31 mergeability: "mergeable" as const,
32 createdAt: "2026-07-01T00:00:00Z",
33 updatedAt: "2026-07-02T00:00:00Z",
34 closedAt: null,
35 body: "",
36 reviewRequestLogins: [],
37 reviewers: [],
38 location: { project: "acme", repository: "web" },
39 autoMergeEnabled: false,
40};
41
42function change(
43 path: string,
44 changeKind: AzureDevOpsChangeEntry["changeKind"] = "change",
45): AzureDevOpsChangeEntry {
46 return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" };
47}
48
49/**
50 * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of
51 * `width` characters each: the diff work and the patch bytes one file costs are both dialled from
52 * here, and they are what a slice is bounded by. Each line carries its own number and a prefix
53 * as well, so `width` is a floor on how long a line is rather than its byte count.
54 */
55function side(prefix: string, lines: number, width: number): string {
56 const pad = "z".repeat(width);
57 return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`;
58}
59
60const readSlice = (input: {
61 readonly paths: ReadonlyArray<string>;
62 readonly lines: number;
63 readonly width: number;
64 /** Paths the host refuses, which is one file's problem rather than the read's. */
65 readonly refused?: ReadonlyArray<string>;
66 /** Paths the change creates, so the host has nothing to hand back for their old side. */
67 readonly created?: ReadonlyArray<string>;
68 readonly cursor?: string;
69}) =>
70 Effect.gen(function* () {
71 const reads: string[] = [];
72 const refused = new Set(input.refused ?? []);
73 const created = new Set(input.created ?? []);
74 let inFlight = 0;
75 let peakInFlight = 0;
76
77 const provider = yield* make.pipe(
78 Effect.provide(
79 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
80 getPullRequest: () => Effect.succeed(PULL_REQUEST),
81 listIterations: () => Effect.succeed([ITERATION]),
82 listIterationChanges: () =>
83 Effect.succeed({
84 changes: input.paths.map((path) =>
85 change(path, created.has(path) ? "new" : "change"),
86 ),
87 truncated: false,
88 }),
89 readItemContent: (item) =>
90 Effect.gen(function* () {
91 reads.push(item.path);
92 inFlight += 1;
93 peakInFlight = Math.max(peakInFlight, inFlight);
94 // Every read suspends before it answers, as a subprocess would, so what runs at
95 // once is the scheduler's answer rather than an artefact of resolving inline. The
96 // later a file is listed the sooner it answers, to leave the assembled patch
97 // nothing but the change list to take its order from.
98 const answersAfter = input.paths.length - input.paths.indexOf(item.path);
99 for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow;
100 inFlight -= 1;
101 if (refused.has(item.path)) {
102 return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
103 command: "az",
104 cwd: "/w",
105 operation: "readItemContent",
106 cause: "refused",
107 });
108 }
109 const isOldSide = item.commit !== ITERATION.headCommit;
110 if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false };
111 return {
112 contents: side(isOldSide ? "old" : "new", input.lines, input.width),
113 isBinary: false,
114 };
115 }),
116 }),
117 ),
118 );
119
120 const slice = yield* provider.getDiff({
121 cwd: "/w",
122 repository: "acme/web",
123 host: "dev.azure.com",
124 number: 7,
125 ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
126 });
127 return { slice, reads, peakInFlight };
128 });
129
130/** Which files the patch carries a section for, in the order it carries them. */
131function patchedPaths(patch: string): ReadonlyArray<string> {
132 return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map(
133 (match) => match.groups?.path ?? "",
134 );
135}
136
137describe("getChangeRequestSummary", () => {
138 it.effect("costs the one pull request read, not the iterations changedFiles needs", () =>
139 Effect.gen(function* () {
140 let pullRequestReads = 0;
141
142 const provider = yield* make.pipe(
143 Effect.provide(
144 // listIterations and listIterationChanges are left unimplemented here, so a summary
145 // that reached for either would die with UnimplementedError instead of this passing.
146 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
147 getPullRequest: () => {
148 pullRequestReads += 1;
149 return Effect.succeed(PULL_REQUEST);
150 },
151 }),
152 ),
153 );
154
155 const readSummary = provider.getChangeRequestSummary;
156 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
157 const summary = yield* readSummary({
158 cwd: "/w",
159 repository: "acme/web",
160 host: "dev.azure.com",
161 number: 7,
162 });
163
164 expect(pullRequestReads).toBe(1);
165 expect(summary.title).toBe(PULL_REQUEST.title);
166 expect(summary.changedFiles).toBeUndefined();
167 }),
168 );
169});
170
171describe("getChangeRequest", () => {
172 it.effect("still reports the file count the detail panel needs", () =>
173 Effect.gen(function* () {
174 const provider = yield* make.pipe(
175 Effect.provide(
176 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
177 getPullRequest: () => Effect.succeed(PULL_REQUEST),
178 listIterations: () => Effect.succeed([ITERATION]),
179 listIterationChanges: () =>
180 Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }),
181 }),
182 ),
183 );
184
185 const detail = yield* provider.getChangeRequest({
186 cwd: "/w",
187 repository: "acme/web",
188 host: "dev.azure.com",
189 number: 7,
190 });
191
192 expect(detail.changedFiles).toBe(2);
193 }),
194 );
195});
196
197describe("getDiff reads", () => {
198 it.effect("holds every reader together to one request's worth of processes", () =>
199 Effect.gen(function* () {
200 // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at
201 // once are two reads, so without a ceiling above them both they are twice a request's
202 // processes, each paying a Python interpreter's start-up on the same machine.
203 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
204 let inFlight = 0;
205 let peakInFlight = 0;
206
207 const provider = yield* make.pipe(
208 Effect.provide(
209 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
210 getPullRequest: () => Effect.succeed(PULL_REQUEST),
211 listIterations: () => Effect.succeed([ITERATION]),
212 listIterationChanges: () =>
213 Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
214 readItemContent: () =>
215 Effect.gen(function* () {
216 inFlight += 1;
217 peakInFlight = Math.max(peakInFlight, inFlight);
218 // Suspends before answering, as a subprocess would, so what is out at once is the
219 // scheduler's answer rather than an artefact of resolving inline.
220 yield* Effect.yieldNow;
221 yield* Effect.yieldNow;
222 inFlight -= 1;
223 return { contents: side("new", 2, 4), isBinary: false };
224 }),
225 }),
226 ),
227 );
228
229 const readDiff = (number: number) =>
230 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
231
232 yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 });
233
234 expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS);
235 }),
236 );
237
238 it.effect("leaves a run of files it could not diff at all for the next slice", () =>
239 Effect.gen(function* () {
240 // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of
241 // hundred bytes with no edits in it, so a change made of them spends neither budget: the
242 // byte one would take well over a thousand of them, and the edit one never fills at all.
243 // A listing holds up to ten thousand entries and each still costs its two reads, which is
244 // what a file count is here to bound.
245 const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`);
246 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths });
247
248 expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES);
249 // Well inside the byte budget, so the file count is what stopped it rather than either of
250 // the budgets that were already there.
251 expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES);
252 expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe(
253 MAX_DIFF_SLICE_FILES,
254 );
255 }),
256 );
257
258 it.effect("asks for both sides of several files at once rather than one side at a time", () =>
259 Effect.gen(function* () {
260 // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a
261 // slice read one side after another is most of what the Code tab waits for.
262 const read = yield* readSlice({
263 paths: ["a.ts", "b.ts", "c.ts", "d.ts"],
264 lines: 2,
265 width: 4,
266 });
267
268 expect(read.peakInFlight).toBeGreaterThan(2);
269 }),
270 );
271
272 it.effect("holds the number of files it reads at once down", () =>
273 Effect.gen(function* () {
274 // The host throttles, and `az` is a process on the same machine the reader runs agents on,
275 // so a long change is read in batches rather than all at once.
276 const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`);
277 const read = yield* readSlice({ paths, lines: 2, width: 4 });
278
279 expect(read.reads).toHaveLength(paths.length * 2);
280 expect(read.peakInFlight).toBeLessThanOrEqual(8);
281 }),
282 );
283
284 it.effect("keeps the patch in the order the change was listed, whoever answered first", () =>
285 Effect.gen(function* () {
286 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
287 const read = yield* readSlice({ paths, lines: 2, width: 4 });
288
289 expect(patchedPaths(read.slice.patch)).toEqual(paths);
290 expect(read.slice.nextCursor).toBeNull();
291 }),
292 );
293
294 it.effect("leaves a file the host refused listed without its hunks, in its place", () =>
295 Effect.gen(function* () {
296 const paths = ["a.ts", "b.ts", "c.ts"];
297 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] });
298
299 expect(patchedPaths(read.slice.patch)).toEqual(paths);
300 expect(read.slice.truncated).toBe(true);
301 // Its section ends at its header, and the files around it still carry their hunks.
302 expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts");
303 expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2);
304 }),
305 );
306});
307
308describe("what one diff slice spends", () => {
309 it.effect("stops on the byte ceiling without carrying what it read past it", () =>
310 Effect.gen(function* () {
311 // Two of these fill the slice, and the batch they were read in reached two files further.
312 // Those two belong to the next slice: carrying them would put the request past a ceiling
313 // that is there to bound what one answer weighs.
314 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
315 const read = yield* readSlice({ paths, lines: 100, width: 900 });
316
317 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
318 expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]);
319 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`);
320 expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"]));
321 }),
322 );
323
324 it.effect("narrows what it reads at once as the slice fills", () =>
325 Effect.gen(function* () {
326 // Four files fit inside the budget and the fifth is past half of what is left of it, so
327 // reading four more would throw most of them away and read them again next slice. Every
328 // file is two `az` invocations, so the batch is judged against what the files before it
329 // weighed rather than left at its full width to the last file.
330 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"];
331 const read = yield* readSlice({ paths, lines: 50, width: 450 });
332
333 expect(read.slice.nextCursor).not.toBeNull();
334 expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch)));
335 }),
336 );
337
338 it.effect("stops once the diff work one request may do is spent", () =>
339 Effect.gen(function* () {
340 // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let
341 // one request hold the thread through a dozen of them. Each of these is half of what one
342 // file is allowed, and the slice ends while there is still room for another.
343 const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`);
344 const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 });
345
346 expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES);
347 expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5));
348 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`);
349 }),
350 );
351
352 it.effect("carries a whole new file and stops the slice on what it weighed", () =>
353 Effect.gen(function* () {
354 // A creation has no edit distance to search out, so no edit bound applies to it and its
355 // section is the whole file. What keeps a run of them from filling one answer is the bytes
356 // they weighed, which the slice has to be charged for.
357 const paths = ["new.ts", "b.ts", "c.ts", "d.ts"];
358 const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] });
359
360 expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]);
361 expect(read.slice.patch).toContain("--- /dev/null");
362 expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@");
363 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
364 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`);
365 }),
366 );
367
368 it.effect("carries on from where the last slice stopped", () =>
369 Effect.gen(function* () {
370 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
371 const read = yield* readSlice({
372 paths,
373 lines: 100,
374 width: 900,
375 cursor: `${ITERATION.id}:2`,
376 });
377
378 expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]);
379 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`);
380 }),
381 );
382 it.effect("keeps the pull request being read, not the one looked up first", () =>
383 Effect.gen(function* () {
384 // Where a pull request lives is read from the pull request itself, so an evicted entry
385 // costs a whole pull request read before any file can be asked for. Ordered by insertion
386 // alone a hit does not renew its entry, so the review being worked through is the first
387 // thing dropped once a listing has walked a cache's worth of cold pull requests.
388 const HOT = 7;
389 const readsOf = new Map<number, number>();
390
391 const provider = yield* make.pipe(
392 Effect.provide(
393 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
394 getPullRequest: (input) =>
395 Effect.sync(() => {
396 readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1);
397 return { ...PULL_REQUEST, number: input.number };
398 }),
399 listIterations: () => Effect.succeed([ITERATION]),
400 listIterationChanges: () =>
401 Effect.succeed({ changes: [change("a.ts")], truncated: false }),
402 readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }),
403 }),
404 ),
405 );
406
407 const readDiff = (number: number) =>
408 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
409
410 yield* readDiff(HOT);
411 // A cache's worth of cold pull requests, with the open one read in between each of them.
412 for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) {
413 yield* readDiff(HOT + 1 + filled);
414 yield* readDiff(HOT);
415 }
416
417 expect(readsOf.get(HOT)).toBe(1);
418 }),
419 );
420});
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+397-28
......@@ -1,21 +1,62 @@
11import * as Effect from "effect/Effect";
2import * as Semaphore from "effect/Semaphore";
23import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
34
45import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 azureDevOpsFilePatch,
8 azureDevOpsUnreadableFilePatch,
9 formatAzureDevOpsDiffCursor,
10 parseAzureDevOpsDiffCursor,
11 MAX_DIFF_SLICE_BYTES,
12 MAX_DIFF_SLICE_EDITS,
13 MAX_DIFF_SLICE_FILES,
14 byteLength,
15 MAX_FILE_DIFF_EDITS,
16 type AzureDevOpsFileTexts,
17} from "./azureDevOpsDiff.ts";
518import {
619 PullRequestProviderError,
720 type PullRequestProviderFailure,
821 type ProviderChangeRequest,
922 type ProviderChangeRequestActivity,
1023 type ProviderChangeRequestDetail,
24 type ProviderChangeRequestSummary,
25 type ProviderDiffSlice,
1126 type PullRequestProviderApi,
1227} from "./PullRequestProvider.ts";
13import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts";
28import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts";
29import type {
30 AzureDevOpsChangeEntry,
31 AzureDevOpsItemContent,
32 AzureDevOpsIteration,
33 AzureDevOpsPullRequest,
34 AzureDevOpsRepositoryLocation,
35} from "./azureDevOpsPullRequestJson.ts";
36
37/**
38 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
39 * Python interpreter's start-up, so reading them one after another is most of what the Code tab
40 * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its
41 * per-file stats with here, and low enough not to swamp the host's throttling or the machine.
42 */
43const DIFF_FILE_CONCURRENCY = 4;
44
45/**
46 * How many `az` processes this build will have out at once, counted across every reader rather
47 * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews
48 * had sixteen Python interpreters starting at once and nothing above them. Held at what one
49 * request at full width spends, so a second reader waits behind the first instead of adding to it.
50 */
51export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;
52
53/** How many pull requests' repository locations one provider remembers at once. */
54export const LOCATION_CACHE_CAPACITY = 128;
1455
1556const CAPABILITIES: PullRequestCapabilities = {
16 // `az repos pr` has no diff command, and the REST route reports changed files without their
17 // contents, so there is no patch to show. The Code tab is hidden rather than empty.
18 diff: false,
57 // Azure serves no patch of its own, so the one the Code tab reads is built here out of the
58 // files an iteration changed and both sides of each of them.
59 diff: true,
1960 // Reading a conversation is a plain REST read, but posting one is not something this can
2061 // claim without having run it, so the composer stays hidden.
2162 comment: false,
......@@ -33,7 +74,8 @@ const CAPABILITIES: PullRequestCapabilities = {
3374 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
3475 search: false,
3576 reactions: false,
36 // With no patch to show there are no lines to write against, so nothing here is offered.
77 // The patch has lines to write against, but writing a remark at all is what Azure is not
78 // offered for here, so nothing in a review is either.
3779 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
3880 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
3981 // lists the ones this repository could name — that lives behind the identity and graph APIs, a
......@@ -44,6 +86,11 @@ const CAPABILITIES: PullRequestCapabilities = {
4486 // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
4587 // Azure DevOps at all, so there is nothing here it could rewrite either.
4688 edit: { changeRequest: true, comment: false },
89 // Azure does keep a viewed record of its own, but only behind the undocumented contribution
90 // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway.
91 // So they are kept here instead, and the client says whose they are rather than implying the
92 // Azure DevOps page will show them.
93 viewedFiles: "environment",
4794};
4895
4996/**
......@@ -85,8 +132,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq
85132 state: pullRequest.state,
86133 isDraft: pullRequest.isDraft,
87134 mergeability: pullRequest.mergeability,
88 // Azure reports no line counts on a pull request, and with no patch to read there is
89 // nothing to count them from either.
135 // Azure counts a pull request's files but never its lines, and counting them here would mean
136 // reading every file on both sides of every row of a listing.
90137 additions: 0,
91138 deletions: 0,
92139 createdAt: pullRequest.createdAt,
......@@ -101,6 +148,11 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq
101148
102149export const make = Effect.gen(function* () {
103150 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
151 // Made once with the provider, which the registry builds once, so this is the whole build's
152 // allowance rather than one request's.
153 const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
154 const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
155 diffSpawns.withPermits(1)(cli.readItemContent(input));
104156
105157 const fail =
106158 (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
......@@ -123,6 +175,123 @@ export const make = Effect.gen(function* () {
123175 }),
124176 );
125177
178 /** A pull request Azure could not place has no diff to read, which reads as an empty one. */
179 const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };
180
181 /**
182 * Where a pull request's repository lives, which is the route every other read of it needs and
183 * the one thing only the pull request itself states. A pull request cannot move between
184 * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for
185 * a whole pull request read every time they checked whether a file had been pushed to. Least
186 * recently used, so a listing walking cold pull requests cannot evict the one being read.
187 */
188 const locations = new Map<string, AzureDevOpsRepositoryLocation>();
189
190 const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
191 const key = `${input.cwd} ${input.number}`;
192 const held = locations.get(key);
193 if (held !== undefined) {
194 locations.delete(key);
195 locations.set(key, held);
196 return Effect.succeed(held);
197 }
198 return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
199 Effect.map((pullRequest) => {
200 const location = pullRequest.location;
201 if (location === null) return null;
202 if (locations.size >= LOCATION_CACHE_CAPACITY) {
203 const oldest = locations.keys().next().value;
204 if (oldest !== undefined) locations.delete(oldest);
205 }
206 locations.set(key, location);
207 return location;
208 }),
209 );
210 };
211
212 /**
213 * Everything a diff read needs before it can ask for a file: where the repository lives, and
214 * which pushes the pull request has had. A client names neither, and the iterations are read
215 * afresh every time because the newest one is what a push adds.
216 */
217 const diffScope = (input: { readonly cwd: string; readonly number: number }) =>
218 Effect.gen(function* () {
219 const location = yield* locationOf(input);
220 if (location === null) return null;
221 const iterations = yield* cli.listIterations({
222 cwd: input.cwd,
223 location,
224 number: input.number,
225 });
226 return { location, iterations };
227 });
228
229 const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };
230
231 /**
232 * Both sides of one changed file, read at once because neither answer depends on the other and
233 * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are
234 * asked for: Azure answers for a file that is not at a commit with a failure rather than with
235 * nothing.
236 */
237 const readTexts = (input: {
238 readonly cwd: string;
239 readonly location: AzureDevOpsRepositoryLocation;
240 readonly iteration: AzureDevOpsIteration;
241 readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
242 }) =>
243 Effect.all(
244 [
245 input.change.changeKind === "new"
246 ? Effect.succeed(EMPTY_ITEM)
247 : readItemContent({
248 cwd: input.cwd,
249 location: input.location,
250 path: input.change.oldPath,
251 commit: input.iteration.mergeBaseCommit,
252 }),
253 input.change.changeKind === "deleted"
254 ? Effect.succeed(EMPTY_ITEM)
255 : readItemContent({
256 cwd: input.cwd,
257 location: input.location,
258 path: input.change.path,
259 commit: input.iteration.headCommit,
260 }),
261 ],
262 { concurrency: 2 },
263 ).pipe(
264 Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
265 oldContents: oldItem.contents,
266 newContents: newItem.contents,
267 // Azure hands a file it calls binary over in an encoding of its own, so its own word on
268 // that is taken rather than looked for in bytes it may never have sent verbatim.
269 binary: oldItem.isBinary || newItem.isBinary,
270 })),
271 );
272
273 /**
274 * What the whole pull request changed, taken from its latest push. An iteration's changes are
275 * reported against the merge base rather than against the push before it, so the newest one is
276 * the whole of the change rather than the last slice of it.
277 */
278 const listLatestChanges = (input: {
279 readonly cwd: string;
280 readonly location: AzureDevOpsRepositoryLocation;
281 readonly number: number;
282 readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
283 }) => {
284 const latest = input.iterations.at(-1);
285 return latest === undefined
286 ? Effect.succeed({ changes: [], truncated: false } as AzureDevOpsIterationChanges)
287 : cli.listIterationChanges({
288 cwd: input.cwd,
289 location: input.location,
290 number: input.number,
291 iterationId: latest.id,
292 });
293 };
294
126295 const provider: PullRequestProviderApi = {
127296 kind: "azure-devops",
128297 capabilities: CAPABILITIES,
......@@ -156,13 +325,52 @@ export const make = Effect.gen(function* () {
156325 })),
157326 ),
158327
159 getChangeRequest: (input) =>
328 // The polled path a linked thread's row stays live on: one `az` read, no iterations or
329 // changes behind it, since the file count that would cost is not shown here.
330 getChangeRequestSummary: (input) =>
160331 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
161 Effect.mapError(fail("getChangeRequest")),
162 Effect.map((pullRequest): ProviderChangeRequestDetail => ({
332 Effect.mapError(fail("getChangeRequestSummary")),
333 Effect.map((pullRequest): ProviderChangeRequestSummary => ({
334 number: pullRequest.number,
335 title: pullRequest.title,
336 url: pullRequest.url,
337 author: pullRequest.author,
338 headBranch: pullRequest.headBranch,
339 baseBranch: pullRequest.baseBranch,
340 state: pullRequest.state,
341 isDraft: pullRequest.isDraft,
342 mergeability: pullRequest.mergeability,
343 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
344 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
345 updatedAt: pullRequest.updatedAt,
346 })),
347 ),
348
349 getChangeRequest: (input) =>
350 Effect.gen(function* () {
351 const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
352 const location = pullRequest.location;
353 // The file count is two reads past the pull request itself, and it is the only thing
354 // riding on them, so a failure leaves it unknown rather than losing the whole detail.
355 const changedFiles =
356 location === null
357 ? 0
358 : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe(
359 Effect.flatMap((iterations) =>
360 listLatestChanges({
361 cwd: input.cwd,
362 location,
363 number: input.number,
364 iterations,
365 }),
366 ),
367 Effect.map((listed) => listed.changes.length),
368 Effect.orElseSucceed(() => 0),
369 );
370 const detail: ProviderChangeRequestDetail = {
163371 ...toChangeRequest(pullRequest),
164372 body: pullRequest.body,
165 changedFiles: 0,
373 changedFiles,
166374 mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
167375 closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
168376 reviewers: pullRequest.reviewers,
......@@ -173,19 +381,26 @@ export const make = Effect.gen(function* () {
173381 ...(pullRequest.autoMergeMethod === undefined
174382 ? {}
175383 : { autoMergeMethod: pullRequest.autoMergeMethod }),
176 })),
177 ),
384 };
385 return detail;
386 }).pipe(Effect.mapError(fail("getChangeRequest"))),
178387
179388 getChangeRequestActivity: (input) =>
180389 cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
181390 Effect.mapError(fail("getChangeRequestActivity")),
182391 Effect.flatMap((pullRequest) =>
183 (pullRequest.threadsUrl === null
392 (pullRequest.location === null
184393 ? Effect.succeed({ comments: [], truncated: true })
185 : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe(
186 Effect.map((comments) => ({ comments, truncated: false })),
187 Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
188 )
394 : cli
395 .listThreads({
396 cwd: input.cwd,
397 location: pullRequest.location,
398 number: input.number,
399 })
400 .pipe(
401 Effect.map((comments) => ({ comments, truncated: false })),
402 Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
403 )
189404 ).pipe(
190405 Effect.map((conversation): ProviderChangeRequestActivity => ({
191406 comments: conversation.comments,
......@@ -202,16 +417,170 @@ export const make = Effect.gen(function* () {
202417 // reach, so the answer is the same constant the detail carries.
203418 getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),
204419
205 // Never called: `capabilities.diff` is false, and the service refuses a diff without it.
206 getDiff: () =>
207 Effect.fail(
208 new PullRequestProviderError({
209 provider: "azure-devops",
210 operation: "getDiff",
211 reason: "failed",
212 detail: "Azure DevOps cannot produce a patch for a pull request.",
213 }),
214 ),
420 // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
421 // the Code tab has nothing to scope itself to and always asks for the whole change.
422 getDiff: (input) =>
423 Effect.gen(function* () {
424 const scope = yield* diffScope(input);
425 if (scope === null) return EMPTY_DIFF_SLICE;
426 const cursor = parseAzureDevOpsDiffCursor(input.cursor);
427 // Reading on stays with the push the first slice was taken against. A push landing
428 // mid-read would otherwise renumber the files and hand the reader one twice, or none.
429 const iteration =
430 cursor === null
431 ? scope.iterations.at(-1)
432 : scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
433 if (iteration === undefined) return EMPTY_DIFF_SLICE;
434 const listed = yield* cli.listIterationChanges({
435 cwd: input.cwd,
436 location: scope.location,
437 number: input.number,
438 iterationId: iteration.id,
439 });
440 const changes = listed.changes;
441
442 const sections: string[] = [];
443 let truncated = listed.truncated;
444 let bytes = 0;
445 let edits = 0;
446 let index = cursor?.fileIndex ?? 0;
447 let full = false;
448 // How many files to read at once. Every file costs a request a side, and everything read
449 // past the point the slice fills is thrown away and read again by the next slice, so the
450 // batch narrows as the budgets do: what is left of each, over what a file has spent of it
451 // on average so far, which is the only estimate there is before a file is read. At least
452 // one, so a file heavier than the whole budget still moves the cursor.
453 const batchWidth = () => {
454 if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
455 const admits = (left: number, spent: number) =>
456 Math.ceil(left / Math.max(1, spent / sections.length));
457 return Math.max(
458 1,
459 Math.min(
460 DIFF_FILE_CONCURRENCY,
461 // The file budget needs no estimate: a file spends exactly one of it.
462 MAX_DIFF_SLICE_FILES - sections.length,
463 admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
464 admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
465 ),
466 );
467 };
468 while (!full && index < changes.length) {
469 const batch = changes.slice(index, index + batchWidth());
470 const read = yield* Effect.forEach(
471 batch,
472 (change) =>
473 // A pair Azure refuses is one file rather than the whole slice: an oversize blob or
474 // a path `az` will not carry through leaves that file listed without its hunks, and
475 // everything around it still renders.
476 readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
477 // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az`
478 // at all is the read failing rather than the file, and belongs to the caller,
479 // which pauses the host rather than showing every file as unreadable.
480 Effect.catchTags({
481 AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
482 AzureDevOpsCommandFailedError: () => Effect.succeed(null),
483 AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
484 }),
485 Effect.map((texts) => ({ change, texts })),
486 ),
487 { concurrency: DIFF_FILE_CONCURRENCY },
488 );
489 for (const { change, texts } of read) {
490 // The diff is synchronous and the reads no longer stand between one file and the next
491 // to let anything else on the server run, so the thread is handed back here.
492 yield* Effect.yieldNow;
493 const file =
494 texts === null
495 ? azureDevOpsUnreadableFilePatch(change)
496 : azureDevOpsFilePatch({ change, texts });
497 sections.push(file.section);
498 bytes += byteLength(file.section);
499 edits += file.edits;
500 truncated = truncated || file.truncated;
501 index += 1;
502 // A file whose diff was given up on spent the whole of what one file is allowed and
503 // has only a header to show for it, and a file the diff never ran on at all weighs
504 // almost nothing in either budget and still costs its two reads. So the file count
505 // bounds the request alongside the bytes.
506 // Checked after the file is added rather than before it, so every slice carries at
507 // least one: a section heavier than the whole budget would otherwise never be added,
508 // and the read would answer the same slice forever without moving the cursor.
509 if (
510 bytes >= MAX_DIFF_SLICE_BYTES ||
511 edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
512 sections.length >= MAX_DIFF_SLICE_FILES ||
513 file.abandoned
514 ) {
515 full = true;
516 break;
517 }
518 }
519 }
520
521 const slice: ProviderDiffSlice = {
522 patch: sections.join(""),
523 truncated,
524 nextCursor:
525 index >= changes.length
526 ? null
527 : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
528 };
529 return slice;
530 }).pipe(Effect.mapError(fail("getDiff"))),
531
532 // The patch is built from whole files, so opening the lines around a hunk is the same two
533 // reads over again rather than a wider request. Read against the latest iteration: nothing in
534 // the request says which push the reader is looking at, and expansion is stale after a
535 // mid-review push on every host here anyway.
536 getDiffFileContents: (input) =>
537 Effect.gen(function* () {
538 const scope = yield* diffScope(input);
539 const iteration = scope?.iterations.at(-1);
540 if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
541 return yield* readTexts({
542 cwd: input.cwd,
543 location: scope.location,
544 iteration,
545 change: {
546 changeKind: input.changeType,
547 path: input.newPath,
548 oldPath: input.oldPath,
549 },
550 });
551 }).pipe(Effect.mapError(fail("getDiffFileContents"))),
552
553 /**
554 * What the head has of each marked file, which is the blob Azure already names on the change
555 * it reports. One read covers every path, since the latest iteration lists the whole change.
556 *
557 * A path the change does not carry is at the empty revision, which is where a file the pull
558 * request deletes sits. When the change was too long to follow to its end, those paths are
559 * left out instead: reporting them as deleted would clear a file nobody has read.
560 */
561 getFileRevisions: (input) =>
562 Effect.gen(function* () {
563 const revisions = new Map<string, string>();
564 if (input.paths.length === 0) return { revisions };
565 const scope = yield* diffScope(input);
566 if (scope === null) return { revisions };
567 const listed = yield* listLatestChanges({
568 ...scope,
569 cwd: input.cwd,
570 number: input.number,
571 });
572 const marked = new Set(input.paths);
573 for (const change of listed.changes) {
574 if (!marked.has(change.path) || change.objectId === null) continue;
575 revisions.set(change.path, change.objectId);
576 }
577 if (!listed.truncated) {
578 for (const path of input.paths) {
579 if (!revisions.has(path)) revisions.set(path, "");
580 }
581 }
582 return { revisions };
583 }).pipe(Effect.mapError(fail("getFileRevisions"))),
215584
216585 runAction: (input) =>
217586 cli
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts+155
......@@ -1,6 +1,8 @@
11import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Duration from "effect/Duration";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
5import * as TestClock from "effect/testing/TestClock";
46
57import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
68import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
......@@ -362,6 +364,159 @@ layer("BitbucketPullRequestApi.layer", (it) => {
362364 }),
363365 );
364366
367 it.effect("reads every file version the patch states, not only the paths asked about", () =>
368 Effect.gen(function* () {
369 mockedRequest.mockReturnValueOnce(
370 Effect.succeed(
371 response(
372 [
373 "diff --git a/a.ts b/a.ts",
374 "index 1111111..2222222 100644",
375 "--- a/a.ts",
376 "+++ b/a.ts",
377 "@@ -1 +1 @@",
378 "-a",
379 "+b",
380 "diff --git a/b.ts b/b.ts",
381 "index 3333333..4444444 100644",
382 "--- a/b.ts",
383 "+++ b/b.ts",
384 "@@ -1 +1 @@",
385 "-c",
386 "+d",
387 "",
388 ].join("\n"),
389 ),
390 ),
391 );
392 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
393
394 const revisions = yield* api.getFileRevisions({
395 repository: "acme/web",
396 number: 71,
397 paths: ["a.ts", "missing.ts"],
398 });
399
400 // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it
401 // too, and the caller holding it is what stops the next tick paying for the patch again.
402 // `missing.ts` was asked about and the whole patch was read without finding it, which is
403 // what a file this pull request deletes looks like, so it is answered as the empty version.
404 assert.deepStrictEqual(
405 [...revisions.revisions],
406 [
407 ["a.ts", "2222222"],
408 ["b.ts", "4444444"],
409 ["missing.ts", ""],
410 ],
411 );
412 assert.strictEqual(revisions.complete, true);
413 expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" });
414 }),
415 );
416
417 it.effect("says nothing about the files past the end of a patch it could not read whole", () =>
418 Effect.gen(function* () {
419 // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at.
420 // Answering for it as deleted would clear a mark on it once and for good.
421 mockedRequest.mockReturnValueOnce(
422 Effect.succeed({
423 body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n",
424 truncated: true,
425 }),
426 );
427 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
428
429 const revisions = yield* api.getFileRevisions({
430 repository: "acme/web",
431 number: 72,
432 paths: ["a.ts", "past-the-cut.ts"],
433 });
434
435 assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]);
436 // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story.
437 assert.strictEqual(revisions.complete, false);
438 }),
439 );
440
441 it.effect("reads the patch once for a run of ticks, not once a tick", () =>
442 Effect.gen(function* () {
443 mockedRequest.mockReturnValue(
444 Effect.succeed(
445 response(
446 [
447 "diff --git a/a.ts b/a.ts",
448 "index 1111111..2222222 100644",
449 "@@ -1 +1 @@",
450 "diff --git a/b.ts b/b.ts",
451 "index 3333333..4444444 100644",
452 "@@ -1 +1 @@",
453 "",
454 ].join("\n"),
455 ),
456 ),
457 );
458 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
459
460 const first = yield* api.getFileRevisions({
461 repository: "acme/web",
462 number: 74,
463 paths: ["a.ts"],
464 });
465 // A path nobody has asked about before, which is what every tick after the first names.
466 const second = yield* api.getFileRevisions({
467 repository: "acme/web",
468 number: 74,
469 paths: ["b.ts"],
470 });
471
472 const both = [
473 ["a.ts", "2222222"],
474 ["b.ts", "4444444"],
475 ];
476 assert.deepStrictEqual([...first.revisions], both);
477 assert.deepStrictEqual([...second.revisions], both);
478 assert.strictEqual(mockedRequest.mock.calls.length, 1);
479 }),
480 );
481
482 it.effect("reads the patch afresh once the one it held has aged out", () =>
483 Effect.gen(function* () {
484 mockedRequest.mockReturnValue(
485 Effect.succeed(
486 response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"),
487 ),
488 );
489 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
490 const read = () =>
491 api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] });
492
493 yield* read();
494 yield* read();
495 assert.strictEqual(mockedRequest.mock.calls.length, 1);
496
497 // Well inside the window the caller holds versions for: a refresh drops what it holds so
498 // that the read after it reaches Bitbucket, and this must not answer that read instead.
499 yield* TestClock.adjust(Duration.seconds(30));
500 yield* read();
501 assert.strictEqual(mockedRequest.mock.calls.length, 2);
502 }),
503 );
504
505 it.effect("asks Bitbucket nothing when no file has been ticked off", () =>
506 Effect.gen(function* () {
507 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
508
509 const revisions = yield* api.getFileRevisions({
510 repository: "acme/web",
511 number: 73,
512 paths: [],
513 });
514
515 assert.strictEqual(revisions.revisions.size, 0);
516 assert.strictEqual(mockedRequest.mock.calls.length, 0);
517 }),
518 );
519
365520 it.effect("aggregates every diffstat page", () =>
366521 Effect.gen(function* () {
367522 const next = "https://api.bitbucket.org/2.0/diffstat?page=2";
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+99-20
......@@ -1,5 +1,8 @@
1import * as Cache from "effect/Cache";
12import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
24import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
36import * as Layer from "effect/Layer";
47import * as Result from "effect/Result";
58import * as Schema from "effect/Schema";
......@@ -19,6 +22,7 @@ import type {
1922} from "@t3tools/contracts";
2023
2124import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
25import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
2226import {
2327 buildReviewThreads,
2428 decodeCommentsJson,
......@@ -135,7 +139,13 @@ const CONVERSATION_PAGE_SIZE = 50;
135139const CONVERSATION_PAGES = 10;
136140/** The same ceiling the gh and glab diff reads use. */
137141const DIFF_MAX_BYTES = 8 * 1024 * 1024;
138
142/**
143 * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the
144 * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it
145 * instead.
146 */
147const REVISION_PATCH_TTL = Duration.seconds(5);
148const REVISION_PATCH_CAPACITY = 16;
139149export interface BitbucketPullRequestBatch {
140150 readonly items: ReadonlyArray<BitbucketPullRequest>;
141151 readonly truncated: boolean;
......@@ -182,6 +192,25 @@ export class BitbucketPullRequestApi extends Context.Service<
182192 readonly number: number;
183193 }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>;
184194
195 /**
196 * What the pull request's head has of each of these paths, as opaque ids, read off the pull
197 * request's own patch, the only place Bitbucket states a file's version. A path the patch does
198 * not carry is answered as the empty revision, and left out altogether when the patch was cut
199 * short at the byte ceiling and so cannot be spoken for.
200 *
201 * Answers with every file the patch carries rather than only the paths asked about, since
202 * reading one file's version here means parsing all of them. `complete` is false for a patch
203 * cut short, which cannot speak for what came after the cut.
204 */
205 readonly getFileRevisions: (input: {
206 readonly repository: string;
207 readonly number: number;
208 readonly paths: ReadonlyArray<string>;
209 }) => Effect.Effect<
210 { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean },
211 BitbucketPullRequestApiError
212 >;
213
185214 readonly getMergeability: (input: {
186215 readonly repository: string;
187216 readonly number: number;
......@@ -525,6 +554,58 @@ export const make = Effect.gen(function* () {
525554 }),
526555 );
527556
557 const pullRequestDiff = (input: {
558 readonly repository: string;
559 readonly number: number;
560 readonly commit?: string | undefined;
561 }): Effect.Effect<
562 { readonly patch: string; readonly truncated: boolean },
563 BitbucketPullRequestApiError
564 > =>
565 input.commit !== undefined && !isCommitSha(input.commit)
566 ? Effect.fail(new BitbucketDiffCommitError())
567 : withRepository(input.repository, (path) =>
568 // Already a unified patch, so it needs no decoding at all, only a bound, which a
569 // diff of any size would otherwise ignore. A commit's own patch sits beside the pull
570 // request's at `/diff/{sha}` and reads the same way.
571 bitbucket
572 .request({
573 method: "GET",
574 url:
575 input.commit === undefined
576 ? `${path}/pullrequests/${input.number}/diff`
577 : `${path}/diff/${input.commit}`,
578 maxBytes: DIFF_MAX_BYTES,
579 })
580 .pipe(
581 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
582 ),
583 );
584
585 /**
586 * What the version reads that come one tick at a time want out of the pull request's whole
587 * patch, shared between them. The parsed answer rather than the patch, which at this capacity
588 * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch
589 * of a hundred thousand lines again on every tick.
590 */
591 const revisionPatches = yield* Cache.makeWith(
592 (key: string) => {
593 const [repository, number] = JSON.parse(key) as [string, number];
594 return pullRequestDiff({ repository, number }).pipe(
595 Effect.map((diff) => ({
596 revisions: parseDiffFileRevisions(diff.patch),
597 truncated: diff.truncated,
598 })),
599 );
600 },
601 {
602 capacity: REVISION_PATCH_CAPACITY,
603 // A failure is not held: the tick after it should reach Bitbucket rather than be handed the
604 // same error for as long as a good patch would have lasted.
605 timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero),
606 },
607 );
608
528609 return BitbucketPullRequestApi.of({
529610 getViewer: () =>
530611 bitbucket.request({ method: "GET", url: "/user" }).pipe(
......@@ -596,25 +677,23 @@ export const make = Effect.gen(function* () {
596677 }),
597678 ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))),
598679
599 getPullRequestDiff: (input) =>
600 input.commit !== undefined && !isCommitSha(input.commit)
601 ? Effect.fail(new BitbucketDiffCommitError())
602 : withRepository(input.repository, (path) =>
603 // Already a unified patch, so it needs no decoding at all — only a bound, which a
604 // diff of any size would otherwise ignore. A commit's own patch sits beside the pull
605 // request's at `/diff/{sha}` and reads the same way.
606 bitbucket
607 .request({
608 method: "GET",
609 url:
610 input.commit === undefined
611 ? `${path}/pullrequests/${input.number}/diff`
612 : `${path}/diff/${input.commit}`,
613 maxBytes: DIFF_MAX_BYTES,
614 })
615 .pipe(
616 Effect.map((response) => ({ patch: response.body, truncated: response.truncated })),
617 ),
680 getPullRequestDiff: pullRequestDiff,
681
682 getFileRevisions: (input) =>
683 input.paths.length === 0
684 ? Effect.succeed({ revisions: new Map(), complete: false })
685 : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe(
686 Effect.map((diff) => {
687 const revisions = new Map(diff.revisions);
688 // A patch cut short at the byte ceiling says nothing about the files past the cut,
689 // so those paths are left out rather than reported as removed.
690 if (!diff.truncated) {
691 for (const path of input.paths) {
692 if (!revisions.has(path)) revisions.set(path, "");
693 }
694 }
695 return { revisions, complete: !diff.truncated };
696 }),
618697 ),
619698
620699 getDiffStat: (input) =>
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+14
......@@ -31,6 +31,11 @@ const CAPABILITIES: PullRequestCapabilities = {
3131 },
3232 reviewers: { request: true, listCandidates: true },
3333 edit: { changeRequest: true, comment: true },
34 // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a
35 // viewed file, and the per-pull-request properties it does offer are one value shared by
36 // everyone rather than one per reader. So the marks are kept here, and the client says whose
37 // they are rather than implying bitbucket.org will show them.
38 viewedFiles: "environment",
3439};
3540
3641/**
......@@ -234,6 +239,15 @@ export const make = Effect.gen(function* () {
234239 Effect.map((diff) => ({ ...diff, nextCursor: null })),
235240 ),
236241
242 getFileRevisions: (input) =>
243 api
244 .getFileRevisions({
245 repository: input.repository,
246 number: input.number,
247 paths: input.paths,
248 })
249 .pipe(Effect.mapError(fail("getFileRevisions"))),
250
237251 // Users only: Bitbucket requests a review of an account, and has no group that stands in for
238252 // one on a pull request.
239253 listReviewerCandidates: (input) =>
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts+13
......@@ -4,6 +4,7 @@ import * as Result from "effect/Result";
44import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
55import { decodeJsonResult } from "@t3tools/shared/schemaJson";
66import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts";
7import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
78import {
89 PullRequestProviderError,
910 type ProviderChangeRequestDetail,
......@@ -34,6 +35,7 @@ import {
3435
3536const CAPABILITIES: PullRequestCapabilities = {
3637 diff: true,
38 viewedFiles: "environment",
3739 comment: true,
3840 actions: ["merge", "close", "reopen", "update-branch"],
3941 mergeMethods: ["merge", "squash", "rebase"],
......@@ -347,6 +349,17 @@ export const make = Effect.gen(function* () {
347349 };
348350 },
349351 ),
352 getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
353 const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
354 if (result.stdoutTruncated) {
355 return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
356 }
357 const revisions = new Map(parseDiffFileRevisions(result.stdout));
358 for (const path of input.paths) {
359 if (!revisions.has(path)) revisions.set(path, "");
360 }
361 return { revisions, complete: true };
362 }),
350363 getDiff: (input) =>
351364 request({
352365 ...input,
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+256-7
......@@ -2838,11 +2838,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
28382838 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
28392839 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
28402840
2841 // Its own pull request: a node id looked up once is remembered for the life of the service.
28412842 yield* cli.setReaction({
28422843 cwd: "/w",
28432844 repository: "acme/web",
28442845 host: "github.com",
2845 number: 7,
2846 number: 21,
28462847 content: "rocket",
28472848 reacted: true,
28482849 });
......@@ -2851,7 +2852,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
28512852 const lookup = callAt(0).args;
28522853 expect(lookup).toContain("owner=acme");
28532854 expect(lookup).toContain("name=web");
2854 expect(lookup).toContain("number=7");
2855 expect(lookup).toContain("number=21");
28552856 // @effect-diagnostics-next-line preferSchemaOverJson:off
28562857 const request = JSON.parse(callAt(1).stdin ?? "") as {
28572858 query: string;
......@@ -2912,7 +2913,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
29122913 cwd: "/w",
29132914 repository: "acme/web",
29142915 host: "github.com",
2915 number: 7,
2916 number: 22,
29162917 ...fields,
29172918 });
29182919
......@@ -2920,21 +2921,21 @@ layer("GitHubPullRequestCli.layer", (it) => {
29202921 yield* rewrite({ body: "A better description." });
29212922 yield* rewrite({ title: "Both", body: "at once." });
29222923
2923 // Each rewrite looks the pull request's node id up first, then mutates.
2924 // One node id lookup for the pull request, then a mutation per rewrite.
29242925 const variablesAt = (index: number) =>
29252926 (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables;
29262927 expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" });
2927 expect(variablesAt(3)).toEqual({
2928 expect(variablesAt(2)).toEqual({
29282929 pullRequestId: "PR_kwDOA",
29292930 body: "A better description.",
29302931 });
2931 expect(variablesAt(5)).toEqual({
2932 expect(variablesAt(3)).toEqual({
29322933 pullRequestId: "PR_kwDOA",
29332934 title: "Both",
29342935 body: "at once.",
29352936 });
29362937 // The reader's own words, so they travel the way every other body does.
2937 expect(callAt(5).args.join(" ")).not.toContain("at once.");
2938 expect(callAt(3).args.join(" ")).not.toContain("at once.");
29382939 }),
29392940 );
29402941
......@@ -3655,4 +3656,252 @@ layer("GitHubPullRequestCli.layer", (it) => {
36553656 expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb");
36563657 }),
36573658 );
3659
3660 it.effect("reads every page of viewed files, and says so when there are too many", () =>
3661 Effect.gen(function* () {
3662 const page = (index: number, hasNextPage: boolean) =>
3663 Effect.succeed(
3664 output(
3665 JSON.stringify({
3666 data: {
3667 repository: {
3668 pullRequest: {
3669 files: {
3670 pageInfo: { hasNextPage, endCursor: `cursor-${index}` },
3671 nodes: [
3672 { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" },
3673 { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" },
3674 ],
3675 },
3676 },
3677 },
3678 },
3679 }),
3680 ),
3681 );
3682 mockedExecute
3683 .mockReturnValueOnce(page(0, true))
3684 .mockReturnValueOnce(page(1, true))
3685 .mockReturnValueOnce(page(2, false));
3686 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3687
3688 const viewed = yield* cli.getPullRequestFilesViewed({
3689 cwd: "/w",
3690 repository: "acme/web",
3691 host: "github.com",
3692 number: 7,
3693 });
3694
3695 assert.strictEqual(mockedExecute.mock.calls.length, 3);
3696 // The first page asks from the start; each one after it carries the cursor before it.
3697 assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after=")));
3698 expect(callAt(1).args).toContain("after=cursor-0");
3699 expect(callAt(2).args).toContain("after=cursor-1");
3700 assert.isFalse(viewed.truncated);
3701 expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
3702 ["src/file0.ts", "viewed"],
3703 ["src/other0.ts", "unviewed"],
3704 ["src/file1.ts", "viewed"],
3705 ["src/other1.ts", "unviewed"],
3706 ["src/file2.ts", "viewed"],
3707 ["src/other2.ts", "unviewed"],
3708 ]);
3709 }),
3710 );
3711
3712 it.effect("stops paging viewed files rather than following a change without end", () =>
3713 Effect.gen(function* () {
3714 mockedExecute.mockReturnValue(
3715 Effect.succeed(
3716 output(
3717 // @effect-diagnostics-next-line preferSchemaOverJson:off
3718 JSON.stringify({
3719 data: {
3720 repository: {
3721 pullRequest: {
3722 files: {
3723 pageInfo: { hasNextPage: true, endCursor: "cursor" },
3724 nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }],
3725 },
3726 },
3727 },
3728 },
3729 }),
3730 ),
3731 ),
3732 );
3733 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3734
3735 const viewed = yield* cli.getPullRequestFilesViewed({
3736 cwd: "/w",
3737 repository: "acme/web",
3738 host: "github.com",
3739 number: 7,
3740 });
3741
3742 assert.strictEqual(mockedExecute.mock.calls.length, 5);
3743 assert.isTrue(viewed.truncated);
3744 assert.strictEqual(viewed.files.length, 5);
3745 }),
3746 );
3747
3748 it.effect("clears and restores a burst of files in one request", () =>
3749 Effect.gen(function* () {
3750 mockedExecute
3751 .mockReturnValueOnce(
3752 Effect.succeed(
3753 // @effect-diagnostics-next-line preferSchemaOverJson:off
3754 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
3755 ),
3756 )
3757 .mockReturnValueOnce(Effect.succeed(output("{}")));
3758 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3759
3760 yield* cli.setPullRequestFilesViewed({
3761 cwd: "/w",
3762 repository: "acme/web",
3763 host: "github.com",
3764 number: 23,
3765 files: [
3766 { path: "src/a.ts", viewed: true },
3767 { path: "src/b.ts", viewed: false },
3768 ],
3769 });
3770
3771 // One request to learn the pull request's node id, one for every press together.
3772 assert.strictEqual(mockedExecute.mock.calls.length, 2);
3773 // @effect-diagnostics-next-line preferSchemaOverJson:off
3774 const sent = JSON.parse(callAt(1).stdin ?? "") as {
3775 query: string;
3776 variables: Record<string, string>;
3777 };
3778 expect(sent.query).toContain("f0: markFileAsViewed");
3779 expect(sent.query).toContain("f1: unmarkFileAsViewed");
3780 expect(sent.variables).toEqual({
3781 pullRequestId: "PR_1",
3782 path0: "src/a.ts",
3783 path1: "src/b.ts",
3784 });
3785 }),
3786 );
3787
3788 it.effect("asks the host nothing when nothing was pressed", () =>
3789 Effect.gen(function* () {
3790 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3791
3792 yield* cli.setPullRequestFilesViewed({
3793 cwd: "/w",
3794 repository: "acme/web",
3795 host: "github.com",
3796 number: 7,
3797 files: [],
3798 });
3799
3800 assert.strictEqual(mockedExecute.mock.calls.length, 0);
3801 }),
3802 );
3803
3804 it.effect("looks a pull request's node id up once, however often it is written to", () =>
3805 Effect.gen(function* () {
3806 mockedExecute
3807 .mockReturnValueOnce(
3808 Effect.succeed(
3809 // @effect-diagnostics-next-line preferSchemaOverJson:off
3810 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })),
3811 ),
3812 )
3813 .mockReturnValue(Effect.succeed(output("{}")));
3814 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3815 const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 };
3816
3817 yield* cli.setPullRequestFilesViewed({
3818 ...pullRequest,
3819 files: [{ path: "src/a.ts", viewed: true }],
3820 });
3821 yield* cli.setPullRequestFilesViewed({
3822 ...pullRequest,
3823 files: [{ path: "src/b.ts", viewed: true }],
3824 });
3825 yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" });
3826
3827 // One lookup, then a mutation per write, every one of them addressed by the id it answered.
3828 assert.strictEqual(mockedExecute.mock.calls.length, 4);
3829 expect(callAt(0).args).toContain("number=24");
3830 const idSentAt = (index: number) =>
3831 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })
3832 .variables.pullRequestId;
3833 expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]);
3834 }),
3835 );
3836
3837 it.effect("does not remember a node id lookup that failed", () =>
3838 Effect.gen(function* () {
3839 mockedExecute
3840 .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')))
3841 .mockReturnValueOnce(
3842 Effect.succeed(
3843 // @effect-diagnostics-next-line preferSchemaOverJson:off
3844 output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })),
3845 ),
3846 )
3847 .mockReturnValueOnce(Effect.succeed(output("{}")));
3848 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3849 const write = () =>
3850 cli.setPullRequestFilesViewed({
3851 cwd: "/w",
3852 repository: "acme/web",
3853 host: "github.com",
3854 number: 25,
3855 files: [{ path: "src/a.ts", viewed: true }],
3856 });
3857
3858 const error = yield* Effect.flip(write());
3859 assert.strictEqual(error._tag, "GitHubPullRequestReadError");
3860
3861 yield* write();
3862
3863 assert.strictEqual(mockedExecute.mock.calls.length, 3);
3864 const idSentAt = (index: number) =>
3865 (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } })
3866 .variables.pullRequestId;
3867 expect(idSentAt(2)).toEqual("PR_25");
3868 }),
3869 );
3870 it.effect("keeps the pull request being ticked through, not the one looked up first", () =>
3871 Effect.gen(function* () {
3872 // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is
3873 // working down is the first thing evicted once a listing has walked a cache's worth of cold
3874 // pull requests, and every press after that pays a round trip again.
3875 // This block shares one cache, so these numbers are its own and it runs last.
3876 const HOT = 9_000;
3877 const lookupsOf = new Map<number, number>();
3878 mockedExecute.mockImplementation((input) => {
3879 const asked = input.args.find((arg) => arg.startsWith("number="));
3880 if (asked === undefined) return Effect.succeed(output("{}"));
3881 const number = Number(asked.slice("number=".length));
3882 lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1);
3883 return Effect.succeed(
3884 output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })),
3885 );
3886 });
3887 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
3888 const tick = (number: number) =>
3889 cli.setPullRequestFilesViewed({
3890 cwd: "/w",
3891 repository: "acme/web",
3892 host: "github.com",
3893 number,
3894 files: [{ path: "src/a.ts", viewed: true }],
3895 });
3896
3897 yield* tick(HOT);
3898 // A cache's worth of cold pull requests, with the open one pressed in between each of them.
3899 for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) {
3900 yield* tick(HOT + 1 + filled);
3901 yield* tick(HOT);
3902 }
3903
3904 assert.strictEqual(lookupsOf.get(HOT), 1);
3905 }),
3906 );
36583907});
apps/server/src/pullRequest/GitHubPullRequestCli.ts+123-2
......@@ -15,6 +15,7 @@ import {
1515 type PullRequestAction,
1616 type PullRequestStackHead,
1717 type PullRequestActor,
18 type PullRequestFileViewed,
1819 type PullRequestInvolvement,
1920 type PullRequestListFilters,
2021 type PullRequestListState,
......@@ -39,10 +40,12 @@ import {
3940 ADD_REACTION_GRAPHQL_MUTATION,
4041 buildReviewSubmissionJson,
4142 buildReviewerRequestJson,
43 buildSetFilesViewedGraphQlMutation,
4244 decodeActorAvatarsJson,
4345 decodePullRequestActivityJson,
4446 decodePullRequestDetailJson,
4547 decodePullRequestFilesJson,
48 decodePullRequestFilesViewedJson,
4649 decodePullRequestHeadsJson,
4750 decodePullRequestListJson,
4851 decodePullRequestNodeIdJson,
......@@ -69,6 +72,7 @@ import {
6972 decodeBaseComparisonJson,
7073 PULL_REQUEST_DETAIL_JSON_FIELDS,
7174 PULL_REQUEST_LIST_JSON_FIELDS,
75 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
7276 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
7377 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
7478 REMOVE_REACTION_GRAPHQL_MUTATION,
......@@ -371,6 +375,19 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
371375
372376/** What the files API serves at most in one response, which is what one slice is made of. */
373377const DIFF_FILES_PAGE_SIZE = 100;
378/**
379 * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL
380 * budget per page, against a change request nobody reviews in one sitting past the first few
381 * hundred files: beyond this the read stops and says it was cut short.
382 */
383const FILES_VIEWED_MAX_PAGES = 5;
384
385/**
386 * How many pull requests' node ids are remembered at once. A long-lived server sees far more of
387 * them than a reader ever has open, and least recently used rather than first in: a listing
388 * walking cold pull requests must not evict the review being ticked through.
389 */
390export const NODE_ID_CACHE_CAPACITY = 128;
374391
375392/**
376393 * Pages of review threads to follow before the conversation is reported as truncated. GitHub
......@@ -416,6 +433,12 @@ export interface GitHubPullRequestDiffSlice {
416433 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
417434}
418435
436export interface GitHubPullRequestFilesViewed {
437 readonly files: ReadonlyArray<PullRequestFileViewed>;
438 /** GitHub had more files than the page budget below would read. */
439 readonly truncated: boolean;
440}
441
419442export class GitHubPullRequestCli extends Context.Service<
420443 GitHubPullRequestCli,
421444 {
......@@ -569,6 +592,29 @@ export class GitHubPullRequestCli extends Context.Service<
569592 GitHubPullRequestCliError
570593 >;
571594
595 /**
596 * Which files of the pull request the signed-in account has cleared, and which of those have
597 * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL.
598 */
599 readonly getPullRequestFilesViewed: (input: {
600 readonly cwd: string;
601 readonly repository: string;
602 readonly host: string;
603 readonly number: number;
604 }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError>;
605
606 /**
607 * Clears files, or puts them back, as one request. GitHub takes a single path per mutation,
608 * so a burst is batched with aliases into one document rather than one subprocess per press.
609 */
610 readonly setPullRequestFilesViewed: (input: {
611 readonly cwd: string;
612 readonly repository: string;
613 readonly host: string;
614 readonly number: number;
615 readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
616 }) => Effect.Effect<void, GitHubPullRequestCliError>;
617
572618 readonly listReviewThreadComments: (input: {
573619 readonly cwd: string;
574620 readonly repository: string;
......@@ -1123,15 +1169,30 @@ export const make = Effect.gen(function* () {
11231169 /**
11241170 * The pull request's own node id, which is what a mutation against the pull request itself is
11251171 * addressed by: a reaction on its description, or a rewrite of its words.
1172 *
1173 * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader
1174 * ticking files viewed would otherwise pay a GraphQL round trip per press.
11261175 */
1176 const nodeIds = new Map<string, string>();
1177
11271178 const pullRequestNodeId = (input: {
11281179 readonly cwd: string;
11291180 readonly repository: string;
11301181 readonly host: string;
11311182 readonly number: number;
11321183 readonly operation: string;
1133 }) => {
1184 }): Effect.Effect<string, GitHubPullRequestCliError> => {
11341185 const { owner, name } = parseRepositorySelector(input.repository);
1186 const key = `${input.host} ${owner}/${name} ${input.number}`;
1187 const held = nodeIds.get(key);
1188 if (held !== undefined) {
1189 // Put back at the end on every hit, so what falls out is the pull request nobody has looked
1190 // at rather than the one being ticked through: a run of cold reads would otherwise evict the
1191 // open review and make it pay a round trip per press.
1192 nodeIds.delete(key);
1193 nodeIds.set(key, held);
1194 return Effect.succeed(held);
1195 }
11351196 return graphqlRead({
11361197 cwd: input.cwd,
11371198 host: input.host,
......@@ -1144,7 +1205,17 @@ export const make = Effect.gen(function* () {
11441205 ],
11451206 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
11461207 decode: decodePullRequestNodeIdJson,
1147 });
1208 }).pipe(
1209 Effect.tap((nodeId) =>
1210 Effect.sync(() => {
1211 if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) {
1212 const oldest = nodeIds.keys().next().value;
1213 if (oldest !== undefined) nodeIds.delete(oldest);
1214 }
1215 nodeIds.set(key, nodeId);
1216 }),
1217 ),
1218 );
11481219 };
11491220
11501221 /**
......@@ -2482,6 +2553,56 @@ export const make = Effect.gen(function* () {
24822553 variables: { threadId: input.threadId, body: input.body },
24832554 }),
24842555
2556 getPullRequestFilesViewed: (input) => {
2557 const { owner, name } = parseRepositorySelector(input.repository);
2558 const read = (
2559 after: string | null,
2560 collected: ReadonlyArray<PullRequestFileViewed>,
2561 pagesLeft: number,
2562 ): Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError> =>
2563 graphqlRead({
2564 cwd: input.cwd,
2565 host: input.host,
2566 operation: "getPullRequestFilesViewed",
2567 variables: [
2568 ["-f", `owner=${owner}`],
2569 ["-f", `name=${name}`],
2570 ["-F", `number=${input.number}`],
2571 ...(after === null
2572 ? []
2573 : ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)),
2574 ],
2575 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
2576 decode: decodePullRequestFilesViewedJson,
2577 }).pipe(
2578 Effect.flatMap((page) => {
2579 const files = [...collected, ...page.files];
2580 if (page.nextCursor === null) {
2581 return Effect.succeed({ files, truncated: false });
2582 }
2583 return pagesLeft <= 1
2584 ? Effect.succeed({ files, truncated: true })
2585 : read(page.nextCursor, files, pagesLeft - 1);
2586 }),
2587 );
2588 return read(null, [], FILES_VIEWED_MAX_PAGES);
2589 },
2590
2591 setPullRequestFilesViewed: (input) => {
2592 const mutation = buildSetFilesViewedGraphQlMutation(input.files);
2593 if (mutation === null) return Effect.void;
2594 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
2595 Effect.flatMap((pullRequestId) =>
2596 graphql({
2597 cwd: input.cwd,
2598 host: input.host,
2599 query: mutation.query,
2600 variables: { pullRequestId, ...mutation.variables },
2601 }),
2602 ),
2603 );
2604 },
2605
24852606 setReviewThreadResolution: (input) =>
24862607 graphql({
24872608 cwd: input.cwd,
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+7
......@@ -40,6 +40,7 @@ const CAPABILITIES: PullRequestCapabilities = {
4040 updateMethods: ["merge", "rebase"],
4141 search: true,
4242 reactions: true,
43 viewedFiles: "host",
4344 review: {
4445 inlineComment: true,
4546 reply: true,
......@@ -576,6 +577,12 @@ export const make = Effect.gen(function* () {
576577 getDiffFileContents: (input) =>
577578 cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),
578579
580 getFilesViewed: (input) =>
581 cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))),
582
583 setFilesViewed: (input) =>
584 cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))),
585
579586 listReviewerCandidates: (input) =>
580587 cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))),
581588
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts+167
......@@ -1402,4 +1402,171 @@ layer("GitLabPullRequestCli.layer", (it) => {
14021402 expect(callAt(0).stdin).toBe('{"body":"true"}');
14031403 }),
14041404 );
1405 it.effect("reads blob ids for the marked paths at the merge request's head", () =>
1406 Effect.gen(function* () {
1407 mockedExecute.mockReturnValueOnce(
1408 Effect.succeed(
1409 output(
1410 // @effect-diagnostics-next-line preferSchemaOverJson:off
1411 JSON.stringify({
1412 iid: 7,
1413 title: "t",
1414 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1415 source_branch: "feat",
1416 target_branch: "main",
1417 created_at: "2026-07-01T00:00:00Z",
1418 updated_at: "2026-07-01T00:00:00Z",
1419 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1420 }),
1421 ),
1422 ),
1423 );
1424 mockedExecute.mockReturnValueOnce(
1425 Effect.succeed(
1426 output(
1427 // @effect-diagnostics-next-line preferSchemaOverJson:off
1428 JSON.stringify({
1429 data: {
1430 project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } },
1431 },
1432 }),
1433 ),
1434 ),
1435 );
1436 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1437
1438 const revisions = yield* cli.getFileRevisions({
1439 cwd: "/w",
1440 repository: "acme/web",
1441 number: 7,
1442 paths: ["src/a.ts", "src/gone.ts"],
1443 });
1444
1445 // Every path was looked for at the head, so one the head does not have is one the merge
1446 // request removed: said as the empty version, which a mark on it was stamped with too.
1447 expect([...revisions]).toEqual([
1448 ["src/a.ts", "aaa"],
1449 ["src/gone.ts", ""],
1450 ]);
1451 // The head the reader is looking at, not whatever the source branch has moved on to.
1452 // @effect-diagnostics-next-line preferSchemaOverJson:off
1453 const body: unknown = JSON.parse(callAt(1).stdin ?? "{}");
1454 expect(body).toMatchObject({
1455 variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] },
1456 });
1457 }),
1458 );
1459
1460 it.effect("leaves the paths out when GitLab did not answer the blobs query", () =>
1461 Effect.gen(function* () {
1462 mockedExecute.mockReturnValueOnce(
1463 Effect.succeed(
1464 output(
1465 // @effect-diagnostics-next-line preferSchemaOverJson:off
1466 JSON.stringify({
1467 iid: 7,
1468 title: "t",
1469 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1470 source_branch: "feat",
1471 target_branch: "main",
1472 created_at: "2026-07-01T00:00:00Z",
1473 updated_at: "2026-07-01T00:00:00Z",
1474 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1475 }),
1476 ),
1477 ),
1478 );
1479 // What GitLab says of a project the token cannot see. It is not the head having none of
1480 // these files, and reading it that way would report every file the reader has cleared as
1481 // changed on nothing worse than a permission.
1482 mockedExecute.mockReturnValueOnce(
1483 Effect.succeed(
1484 // @effect-diagnostics-next-line preferSchemaOverJson:off
1485 output(JSON.stringify({ data: { project: null } })),
1486 ),
1487 );
1488 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1489
1490 const revisions = yield* cli.getFileRevisions({
1491 cwd: "/w",
1492 repository: "acme/web",
1493 number: 7,
1494 paths: ["src/a.ts", "src/b.ts"],
1495 });
1496
1497 expect([...revisions]).toEqual([]);
1498 }),
1499 );
1500
1501 it.effect("asks GitLab nothing when no file is marked", () =>
1502 Effect.gen(function* () {
1503 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1504
1505 const revisions = yield* cli.getFileRevisions({
1506 cwd: "/w",
1507 repository: "acme/web",
1508 number: 7,
1509 paths: [],
1510 });
1511
1512 expect([...revisions]).toEqual([]);
1513 expect(mockedExecute).not.toHaveBeenCalled();
1514 }),
1515 );
1516
1517 it.effect("splits the paths across requests, because GitLab charges the query by how many", () =>
1518 Effect.gen(function* () {
1519 const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`);
1520 mockedExecute.mockReturnValueOnce(
1521 Effect.succeed(
1522 output(
1523 // @effect-diagnostics-next-line preferSchemaOverJson:off
1524 JSON.stringify({
1525 iid: 7,
1526 title: "t",
1527 web_url: "https://gitlab.com/acme/web/-/merge_requests/7",
1528 source_branch: "feat",
1529 target_branch: "main",
1530 created_at: "2026-07-01T00:00:00Z",
1531 updated_at: "2026-07-01T00:00:00Z",
1532 diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" },
1533 }),
1534 ),
1535 ),
1536 );
1537 mockedExecute.mockImplementation((request) => {
1538 const body = JSON.parse(request.stdin ?? "{}") as {
1539 readonly variables: { readonly paths: ReadonlyArray<string> };
1540 };
1541 return Effect.succeed(
1542 output(
1543 JSON.stringify({
1544 data: {
1545 project: {
1546 repository: {
1547 blobs: {
1548 nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })),
1549 },
1550 },
1551 },
1552 },
1553 }),
1554 ),
1555 );
1556 });
1557 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1558
1559 const revisions = yield* cli.getFileRevisions({
1560 cwd: "/w",
1561 repository: "acme/web",
1562 number: 7,
1563 paths,
1564 });
1565
1566 assert.strictEqual(revisions.size, 150);
1567 assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts");
1568 // The diff refs, then two batches: a hundred paths and the fifty left over.
1569 assert.strictEqual(mockedExecute.mock.calls.length, 3);
1570 }),
1571 );
14051572});
apps/server/src/pullRequest/GitLabPullRequestCli.ts+101
......@@ -35,8 +35,10 @@ import {
3535 decodeOwnAwardIdJson,
3636 decodeProjectMergeCapabilitiesJson,
3737 decodeProjectUsersJson,
38 decodeRepositoryBlobsJson,
3839 decodeViewerJson,
3940 gitLabAwardName,
41 REPOSITORY_BLOBS_GRAPHQL_QUERY,
4042 type GitLabDiffRefs,
4143 type GitLabMergeRequestDetail,
4244 type GitLabMergeRequestListItem,
......@@ -283,6 +285,21 @@ export class GitLabPullRequestCli extends Context.Service<
283285 readonly repository: string;
284286 }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;
285287
288 /**
289 * What the merge request's head has of each of these paths, as blob ids.
290 *
291 * The head sha comes from the merge request's own diff refs, so the answer is the version a
292 * reader is looking at rather than whatever the source branch has moved on to. A path the
293 * head does not have is answered as the empty revision, since the batch it was asked in was
294 * looked at; a batch GitLab did not answer for is left out instead.
295 */
296 readonly getFileRevisions: (input: {
297 readonly cwd: string;
298 readonly repository: string;
299 readonly number: number;
300 readonly paths: ReadonlyArray<string>;
301 }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>;
302
286303 /**
287304 * Who this merge request may be sent to, and who it has already been sent to. Two reads at
288305 * 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* () {
10311048 }),
10321049 );
10331050
1051 /**
1052 * GitLab charges the blobs query by how many paths it is handed, and its connection hands back
1053 * one page. A hundred at a time keeps each request inside both.
1054 */
1055 const BLOB_PATHS_PER_REQUEST = 100;
1056
1057 const blobsAt = (input: {
1058 readonly cwd: string;
1059 readonly repository: string;
1060 readonly ref: string;
1061 readonly paths: ReadonlyArray<string>;
1062 }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> =>
1063 api({
1064 cwd: input.cwd,
1065 path: "graphql",
1066 method: "POST",
1067 stdin: JSON.stringify({
1068 query: REPOSITORY_BLOBS_GRAPHQL_QUERY,
1069 variables: { fullPath: input.repository, ref: input.ref, paths: input.paths },
1070 }),
1071 }).pipe(
1072 Effect.flatMap(
1073 (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => {
1074 const decoded = decodeRepositoryBlobsJson(result.stdout.trim());
1075 return Result.isSuccess(decoded)
1076 ? Effect.succeed(decoded.success)
1077 : Effect.fail(
1078 new GitLabMergeRequestReadError({
1079 command: "glab",
1080 cwd: input.cwd,
1081 operation: "getFileRevisions",
1082 cause: decoded.failure,
1083 }),
1084 );
1085 },
1086 ),
1087 );
1088
1089 const fileRevisions = (input: {
1090 readonly cwd: string;
1091 readonly repository: string;
1092 readonly number: number;
1093 readonly paths: ReadonlyArray<string>;
1094 }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> =>
1095 input.paths.length === 0
1096 ? Effect.succeed(new Map())
1097 : getDiffRefs(input).pipe(
1098 Effect.flatMap((refs) => {
1099 const batches: Array<ReadonlyArray<string>> = [];
1100 for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) {
1101 batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST));
1102 }
1103 return Effect.forEach(
1104 batches,
1105 (paths) =>
1106 blobsAt({ ...input, ref: refs.headSha, paths }).pipe(
1107 Effect.map((page) => ({ paths, page })),
1108 ),
1109 { concurrency: 2 },
1110 ).pipe(
1111 Effect.map((pages) => {
1112 const revisions = new Map<string, string>();
1113 for (const { paths, page } of pages) {
1114 // A batch GitLab did not answer says nothing about its paths, so they are left
1115 // out and the caller reads them as versions it could not learn, which leaves
1116 // the marks on them alone. Filling them in as removed would report every file
1117 // a reader has cleared as changed over a project the token cannot see.
1118 if (page === null) continue;
1119 for (const [path, oid] of page) revisions.set(path, oid);
1120 // Within a batch that was answered, every path was looked for at the head, so
1121 // one that is not there is one the merge request removed. Said as the empty
1122 // revision, which is an answer the caller can compare against and keep.
1123 for (const path of paths) {
1124 if (!revisions.has(path)) revisions.set(path, "");
1125 }
1126 }
1127 return revisions as ReadonlyMap<string, string>;
1128 }),
1129 );
1130 }),
1131 );
1132
10341133 const viewerUsername = (input: { readonly cwd: string }) =>
10351134 api({ cwd: input.cwd, path: "user" }).pipe(
10361135 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
......@@ -1066,6 +1165,8 @@ export const make = Effect.gen(function* () {
10661165
10671166 listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),
10681167
1168 getFileRevisions: fileRevisions,
1169
10691170 setReaction: (input) =>
10701171 Effect.gen(function* () {
10711172 const subject = awardSubjectPath(input);
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+14
......@@ -35,6 +35,11 @@ const CAPABILITIES: PullRequestCapabilities = {
3535 updateMethods: ["rebase"],
3636 search: true,
3737 reactions: true,
38 // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside
39 // that browser can read or write them. So the marks made here are this environment's own: they
40 // follow the reader between the clients connected to it, but they are not the ones gitlab.com
41 // shows, and the surface says so rather than implying a review can be carried on from there.
42 viewedFiles: "environment",
3843 review: {
3944 inlineComment: true,
4045 reply: true,
......@@ -218,6 +223,15 @@ export const make = Effect.gen(function* () {
218223
219224 getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
220225
226 // What each marked file is at the head, which is what tells a mark that still stands from one
227 // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too,
228 // so this stales at the same moment its web UI would.
229 getFileRevisions: (input) =>
230 cli.getFileRevisions(input).pipe(
231 Effect.mapError(fail("getFileRevisions")),
232 Effect.map((revisions) => ({ revisions })),
233 ),
234
221235 // Users only: GitLab requests a review of a person, and the groups that can stand in for one
222236 // appear in approval rules rather than in a merge request's reviewers.
223237 listReviewerCandidates: (input) =>
apps/server/src/pullRequest/PullRequestProvider.ts+61
......@@ -10,6 +10,7 @@ import type {
1010 PullRequestChecksState,
1111 PullRequestCheck,
1212 PullRequestComment,
13 PullRequestFileViewed,
1314 PullRequestCommit,
1415 PullRequestInvolvement,
1516 PullRequestLabel,
......@@ -258,6 +259,32 @@ export interface ProviderDiffFileContents {
258259 readonly newContents: string;
259260}
260261
262export interface ProviderFilesViewed {
263 readonly files: ReadonlyArray<PullRequestFileViewed>;
264 /** The host has more files than were read, so the ones missing here are not "unviewed". */
265 readonly truncated: boolean;
266}
267
268/**
269 * What version each of the asked-for files is at, on the change request's head. Opaque strings,
270 * compared only against one another, where the empty string is the answer for a file the change
271 * request deletes rather than a gap. A path is absent only where the read could not say, so a
272 * provider converts its host's own absences on the way here.
273 *
274 * The whole path a version travels, and the three senses of null along it, are in
275 * `docs/internals/pull-request-file-revisions.md`.
276 */
277export interface ProviderFileRevisions {
278 readonly revisions: ReadonlyMap<string, string>;
279 /**
280 * Whether these are every file the change request carries rather than only the paths asked
281 * about. A host with no per-file version reads the whole change to answer for one file, and
282 * saying so is what keeps the next tick, naming a path nothing asked about before, from making
283 * it read the whole change again. False for a read cut short, which cannot speak past the cut.
284 */
285 readonly complete?: boolean;
286}
287
261288export interface ProviderRepositoryRef {
262289 readonly cwd: string;
263290 /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */
......@@ -448,6 +475,40 @@ export interface PullRequestProviderApi {
448475 },
449476 ) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>;
450477
478 /**
479 * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is
480 * `"host"`, and read apart from the patch: this moves with every press rather than every push.
481 */
482 readonly getFilesViewed?: (
483 input: ProviderRepositoryRef & { readonly number: number },
484 ) => Effect.Effect<ProviderFilesViewed, PullRequestProviderError>;
485
486 /**
487 * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`.
488 * A host with no bulk form is still owed one round trip for the batch rather than one per file,
489 * since the point of gathering presses is that the host is asked once.
490 */
491 readonly setFilesViewed?: (
492 input: ProviderRepositoryRef & {
493 readonly number: number;
494 readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
495 },
496 ) => Effect.Effect<void, PullRequestProviderError>;
497
498 /**
499 * What version the head has of each of these files. Required of a host whose
500 * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself:
501 * the marks live here, but only the host can say whether what a reader cleared last week is
502 * still what is in front of them. Asked for the marked paths alone, so the cost follows how
503 * much of the change request has been read rather than how large it is.
504 */
505 readonly getFileRevisions?: (
506 input: ProviderRepositoryRef & {
507 readonly number: number;
508 readonly paths: ReadonlyArray<string>;
509 },
510 ) => Effect.Effect<ProviderFileRevisions, PullRequestProviderError>;
511
451512 readonly runAction: (
452513 input: ProviderRepositoryRef & {
453514 readonly number: number;
apps/server/src/pullRequest/PullRequestService.test.ts+1422-4
......@@ -1,4 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
23import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";
34import { assert, it } from "@effect/vitest";
45import * as Deferred from "effect/Deferred";
......@@ -18,8 +19,12 @@ import type {
1819import { PullRequestOperationError } from "@t3tools/contracts";
1920
2021import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
22import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
23import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
2124import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
2225import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
26import { ForgejoCli } from "../sourceControl/ForgejoCli.ts";
27import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
2328import {
2429 PullRequestProviderError,
2530 type ProviderChangeRequest,
......@@ -28,6 +33,10 @@ import {
2833import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts";
2934import * as PullRequestService from "./PullRequestService.ts";
3035import * as PullRequestReadCache from "./PullRequestReadCache.ts";
36import {
37 FILE_REVISIONS_CACHE_CAPACITY,
38 MAX_FILE_REVISION_PATHS,
39} from "./pullRequestViewedFiles.ts";
3140
3241function project(input: {
3342 readonly id: string;
......@@ -184,8 +193,10 @@ function makeService(input: {
184193 readonly providers: ReadonlyArray<PullRequestProviderApi>;
185194 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
186195}) {
187 return PullRequestService.make.pipe(
188 Effect.provide(
196 // Built into the test's own scope rather than provided call by call: the marks store owns a
197 // database, and `Effect.provide` would close it the moment the service was handed back.
198 return Effect.flatMap(
199 Layer.build(
189200 Layer.mergeAll(
190201 Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)),
191202 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
......@@ -202,12 +213,16 @@ function makeService(input: {
202213 Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))),
203214 }),
204215 SourceControlRateLimit.layer,
216 // The real store over a database of its own, so the environment-kept marks are exercised
217 // through the SQL that holds them rather than through a stand-in that agrees with itself.
218 PullRequestFilesViewed.layer.pipe(Layer.provide(SqlitePersistenceMemory)),
205219 Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe(
206220 Layer.provide(KeyValueStore.layerMemory),
207221 Layer.provide(NodeServices.layer),
208222 ),
209223 ),
210224 ),
225 (context) => Effect.provideContext(PullRequestService.make, context),
211226 );
212227}
213228
......@@ -4002,7 +4017,7 @@ it.effect("shares linked summaries and reuses them for display without asking th
40024017
40034018it.effect("keeps routed reads separate when the GitHub account changes", () =>
40044019 Effect.gen(function* () {
4005 for (const operation of ["summary", "detail", "diff"] as const) {
4020 for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) {
40064021 let failing = false;
40074022 let calls = 0;
40084023 const read = () =>
......@@ -4018,6 +4033,14 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
40184033 ],
40194034 providers: [
40204035 fakeProvider("github", {
4036 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },
4037 getFilesViewed: () =>
4038 read().pipe(
4039 Effect.as({
4040 files: [{ path: "private.ts", state: "viewed" as const }],
4041 truncated: false,
4042 }),
4043 ),
40214044 getChangeRequestSummary: read,
40224045 getChangeRequest: read,
40234046 getDiff: () =>
......@@ -4048,7 +4071,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () =>
40484071
40494072it.effect("isolates routed caches for two credentials belonging to the same account", () =>
40504073 Effect.gen(function* () {
4051 for (const operation of ["summary", "detail", "diff"] as const) {
4074 for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) {
40524075 let credential = "broad";
40534076 let calls = 0;
40544077 const read = () =>
......@@ -4064,6 +4087,14 @@ it.effect("isolates routed caches for two credentials belonging to the same acco
40644087 ],
40654088 providers: [
40664089 fakeProvider("github", {
4090 capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" },
4091 getFilesViewed: () =>
4092 read().pipe(
4093 Effect.as({
4094 files: [{ path: "private.ts", state: "viewed" as const }],
4095 truncated: false,
4096 }),
4097 ),
40674098 withVerifiedCredential: (_, use) =>
40684099 Effect.suspend(() =>
40694100 use({
......@@ -5089,6 +5120,1393 @@ it.effect("names the signed-in account in the detail, and says nothing where the
50895120 }),
50905121);
50915122
5123it.effect("keeps the diff cached across a file being ticked off", () =>
5124 Effect.gen(function* () {
5125 let diffReads = 0;
5126 let viewedReads = 0;
5127 let state: "viewed" | "dismissed" = "viewed";
5128 const service = yield* makeService({
5129 projects: [
5130 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
5131 ],
5132 providers: [
5133 fakeProvider("github", {
5134 capabilities: {
5135 diff: true,
5136 comment: true,
5137 actions: ["merge"],
5138 mergeMethods: ["merge"],
5139 search: true,
5140 reactions: true,
5141 viewedFiles: "host",
5142 review: FULL_REVIEW,
5143 reviewers: FULL_REVIEWERS,
5144 },
5145 getDiff: () => {
5146 diffReads += 1;
5147 return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null });
5148 },
5149 getFilesViewed: () => {
5150 viewedReads += 1;
5151 return Effect.succeed({
5152 files: [{ path: "src/a.ts", state }],
5153 truncated: false,
5154 });
5155 },
5156 setFilesViewed: () => Effect.void,
5157 }),
5158 ],
5159 });
5160 const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 };
5161
5162 yield* service.diff(reference);
5163 yield* service.filesViewed(reference);
5164 yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] });
5165 yield* service.diff(reference);
5166 yield* service.filesViewed(reference);
5167
5168 // The press forgets only the reader's own ticks: a diff of any size survives it.
5169 assert.strictEqual(diffReads, 1);
5170 assert.strictEqual(viewedReads, 2);
5171
5172 state = "dismissed";
5173 yield* service.invalidate({ reference, filesViewedOnly: true });
5174 yield* service.diff(reference);
5175 assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [
5176 { path: "src/a.ts", state: "dismissed" },
5177 ]);
5178 assert.strictEqual(diffReads, 1);
5179 assert.strictEqual(viewedReads, 3);
5180 }),
5181);
5182
5183const environmentViewedProvider = (
5184 revisions: Map<string, string>,
5185 asked: Array<ReadonlyArray<string>>,
5186 unreadable: ReadonlySet<string> = new Set(),
5187) =>
5188 fakeProvider("gitlab", {
5189 capabilities: {
5190 diff: true,
5191 comment: true,
5192 actions: ["merge"],
5193 mergeMethods: ["merge"],
5194 search: true,
5195 reactions: true,
5196 viewedFiles: "environment",
5197 review: FULL_REVIEW,
5198 reviewers: FULL_REVIEWERS,
5199 },
5200 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5201 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5202 // A merge confirms itself against the host before it says the change request landed.
5203 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
5204 getFileRevisions: (input) => {
5205 asked.push(input.paths);
5206 return Effect.succeed({
5207 // A path the host looked at and did not find is at the empty version, which is what a
5208 // file the change request deletes is at. One it could not look at is left out entirely.
5209 revisions: new Map(
5210 input.paths.flatMap((path) =>
5211 unreadable.has(path) ? [] : [[path, revisions.get(path) ?? ""] as const],
5212 ),
5213 ),
5214 });
5215 },
5216 });
5217
5218const environmentViewedService = (
5219 revisions: Map<string, string>,
5220 asked: Array<ReadonlyArray<string>>,
5221 unreadable: ReadonlySet<string> = new Set(),
5222) =>
5223 makeService({
5224 projects: [
5225 project({
5226 id: "p1",
5227 title: "on gitlab",
5228 workspaceRoot: "/a",
5229 repository: "group/project",
5230 provider: "gitlab",
5231 }),
5232 ],
5233 providers: [environmentViewedProvider(revisions, asked, unreadable)],
5234 });
5235
5236const GITLAB_REFERENCE = {
5237 projectId: "p1" as ProjectId,
5238 repository: "group/project",
5239 number: 1,
5240};
5241
5242it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () =>
5243 Effect.gen(function* () {
5244 let alpha = "b485fe5";
5245 let truncated = false;
5246 let diffReads = 0;
5247 const provider = yield* ForgejoPullRequestProvider.make.pipe(
5248 Effect.provide(
5249 Layer.mock(ForgejoCli)({
5250 api: (input) => {
5251 assert.strictEqual(input.host, "forge.example:3000");
5252 const viewer = input.path === "user";
5253 if (!viewer) {
5254 assert.strictEqual(input.repository, "reviewer/project");
5255 assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff");
5256 diffReads++;
5257 }
5258 return Effect.succeed({
5259 exitCode: ChildProcessSpawner.ExitCode(0),
5260 stdout: viewer
5261 ? JSON.stringify({ login: "reviewer" })
5262 : truncated
5263 ? `diff --git a/alpha.ts b/alpha.ts\nindex eb2d7c5..${alpha} 100644\n`
5264 : [
5265 "diff --git a/alpha.ts b/alpha.ts",
5266 `index eb2d7c5..${alpha} 100644`,
5267 "diff --git a/beta.ts b/beta.ts",
5268 "index b39e8b3..5851425 100644",
5269 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"',
5270 "index ffd99ce..6dd9855 100644",
5271 "diff --git a/deleted.txt b/deleted.txt",
5272 "deleted file mode 100644",
5273 "index 233f5c6..0000000",
5274 "diff --git a/old.txt b/renamed.txt",
5275 "similarity index 100%",
5276 "rename from old.txt",
5277 "rename to renamed.txt",
5278 "",
5279 ].join("\n"),
5280 stderr: "",
5281 stdoutTruncated: !viewer && truncated,
5282 stderrTruncated: false,
5283 });
5284 },
5285 }),
5286 ),
5287 );
5288 const service = yield* makeService({
5289 projects: [
5290 project({
5291 id: "forgejo",
5292 title: "on forgejo",
5293 workspaceRoot: "/forgejo",
5294 repository: "reviewer/project",
5295 provider: "forgejo",
5296 host: "forge.example:3000",
5297 remoteUrl: "http://forge.example:3000/reviewer/project.git",
5298 }),
5299 ],
5300 providers: [provider],
5301 });
5302 const reference = {
5303 projectId: "forgejo" as ProjectId,
5304 repository: "reviewer/project",
5305 host: "forge.example:3000",
5306 number: 1,
5307 };
5308 const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"];
5309 yield* service.setFilesViewed({
5310 ...reference,
5311 files: paths.map((path) => ({ path, viewed: true })),
5312 });
5313 assert.deepStrictEqual(
5314 new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])),
5315 new Map(paths.map((path) => [path, "viewed"])),
5316 );
5317 assert.strictEqual(diffReads, 1);
5318
5319 alpha = "aabbccd";
5320 yield* service.invalidate({ reference });
5321 assert.deepStrictEqual(
5322 new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])),
5323 new Map(paths.map((path) => [path, path === "alpha.ts" ? "dismissed" : "viewed"])),
5324 );
5325 yield* service.setFilesViewed({
5326 ...reference,
5327 files: [{ path: "beta.ts", viewed: false }],
5328 });
5329 assert.isFalse(
5330 (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"),
5331 );
5332
5333 truncated = true;
5334 yield* service.invalidate({ reference });
5335 yield* service.setFilesViewed({
5336 ...reference,
5337 files: [{ path: "beta.ts", viewed: true }],
5338 });
5339 truncated = false;
5340 yield* service.invalidate({ reference });
5341 // A partial response must not stamp an empty revision and then dismiss the mark on recovery.
5342 assert.deepStrictEqual(
5343 (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"),
5344 { path: "beta.ts", state: "viewed" },
5345 );
5346 }),
5347);
5348
5349it.effect("keeps hosted Forgejo marks with their repository instead of the serving checkout", () =>
5350 Effect.gen(function* () {
5351 const projects = [
5352 project({
5353 id: "p1",
5354 title: "first repository",
5355 workspaceRoot: "/first",
5356 repository: "reviewer/first",
5357 provider: "forgejo",
5358 host: "forge.example",
5359 remoteUrl: "https://forge.example:3000/reviewer/first.git",
5360 }),
5361 ];
5362 const service = yield* makeService({
5363 projects,
5364 providers: [
5365 { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" },
5366 ],
5367 resolveHandle: ({ context }) =>
5368 Effect.succeed({
5369 context: {
5370 ...context!,
5371 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forge.example:3000" },
5372 },
5373 provider: undefined as never,
5374 }),
5375 });
5376 const first = {
5377 projectId: "p1" as ProjectId,
5378 host: "forge.example:3000",
5379 repository: "reviewer/first",
5380 number: 1,
5381 };
5382 const second = { ...first, repository: "reviewer/second" };
5383 const files = [{ path: "same.ts", viewed: true }];
5384 yield* service.setFilesViewed({ ...first, files });
5385 assert.deepStrictEqual((yield* service.filesViewed(second)).files, []);
5386 yield* service.setFilesViewed({ ...second, files });
5387 yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] });
5388 assert.deepStrictEqual((yield* service.filesViewed(second)).files, [
5389 { path: "same.ts", state: "viewed" },
5390 ]);
5391
5392 projects.push(
5393 project({
5394 id: "p2",
5395 title: "second repository",
5396 workspaceRoot: "/second",
5397 repository: "reviewer/second",
5398 provider: "forgejo",
5399 host: "forge.example",
5400 remoteUrl: "https://forge.example:3000/reviewer/second.git",
5401 }),
5402 );
5403 assert.deepStrictEqual(
5404 (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files,
5405 [{ path: "same.ts", state: "viewed" }],
5406 );
5407 projects.push(
5408 project({
5409 id: "ssh",
5410 title: "second repository over SSH",
5411 workspaceRoot: "/ssh",
5412 repository: "reviewer/second",
5413 provider: "forgejo",
5414 host: "ssh.forge.example",
5415 remoteUrl: "git@ssh.forge.example:reviewer/second.git",
5416 }),
5417 );
5418 assert.deepStrictEqual(
5419 (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files,
5420 [{ path: "same.ts", state: "viewed" }],
5421 );
5422 }),
5423);
5424
5425it.effect("keeps viewed files itself for a host that keeps none of its own", () =>
5426 Effect.gen(function* () {
5427 const asked: Array<ReadonlyArray<string>> = [];
5428 const service = yield* environmentViewedService(
5429 new Map([
5430 ["src/a.ts", "blob-a"],
5431 ["src/b.ts", "blob-b"],
5432 ]),
5433 asked,
5434 );
5435
5436 // Nothing marked is nothing to ask the host about.
5437 const empty = yield* service.filesViewed(GITLAB_REFERENCE);
5438 assert.deepStrictEqual(empty, { files: [], truncated: false });
5439 assert.deepStrictEqual(asked, []);
5440
5441 yield* service.setFilesViewed({
5442 ...GITLAB_REFERENCE,
5443 files: [
5444 { path: "src/a.ts", viewed: true },
5445 { path: "src/b.ts", viewed: true },
5446 ],
5447 });
5448 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5449
5450 assert.deepStrictEqual(
5451 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5452 [
5453 { path: "src/a.ts", state: "viewed" },
5454 { path: "src/b.ts", state: "viewed" },
5455 ],
5456 );
5457 assert.strictEqual(marked.truncated, false);
5458 // The marked paths alone, so the cost follows how much has been read rather than PR size,
5459 // and the read after the press is answered from what the press already heard.
5460 assert.deepStrictEqual(
5461 asked.map((paths) => [...paths].toSorted()),
5462 [["src/a.ts", "src/b.ts"]],
5463 );
5464 }),
5465);
5466
5467it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () =>
5468 Effect.gen(function* () {
5469 const asked: Array<ReadonlyArray<string>> = [];
5470 const head = new Map([
5471 ["src/a.ts", "blob-a"],
5472 ["src/b.ts", "blob-b"],
5473 ]);
5474 const service = yield* makeService({
5475 projects: [
5476 project({
5477 id: "p1",
5478 title: "on gitlab",
5479 workspaceRoot: "/a",
5480 repository: "group/project",
5481 provider: "gitlab",
5482 }),
5483 ],
5484 providers: [
5485 {
5486 ...environmentViewedProvider(head, []),
5487 // A host with no per-file version reads the whole change to answer for one file, which
5488 // is what Bitbucket's patch is, and says as much.
5489 getFileRevisions: (input) => {
5490 asked.push(input.paths);
5491 return Effect.succeed({ revisions: head, complete: true });
5492 },
5493 },
5494 ],
5495 });
5496
5497 yield* service.setFilesViewed({
5498 ...GITLAB_REFERENCE,
5499 files: [{ path: "src/a.ts", viewed: true }],
5500 });
5501 // A path nothing has asked about before, which is what every tick after the first names. Its
5502 // version came back with the first answer, so there is nothing left to read it for.
5503 yield* service.setFilesViewed({
5504 ...GITLAB_REFERENCE,
5505 files: [{ path: "src/b.ts", viewed: true }],
5506 });
5507
5508 assert.deepStrictEqual(asked, [["src/a.ts"]]);
5509 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5510 assert.deepStrictEqual(
5511 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5512 [
5513 { path: "src/a.ts", state: "viewed" },
5514 { path: "src/b.ts", state: "viewed" },
5515 ],
5516 );
5517
5518 // Still only as fresh as the read it came from: past that window the press reads again rather
5519 // than stamping a mark with a version the head may have moved off.
5520 yield* TestClock.adjust("2 minutes");
5521 yield* service.setFilesViewed({
5522 ...GITLAB_REFERENCE,
5523 files: [{ path: "src/b.ts", viewed: true }],
5524 });
5525 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
5526 }),
5527);
5528
5529it.effect("reads the marks without asking the host what the head has every time", () =>
5530 Effect.gen(function* () {
5531 const asked: Array<ReadonlyArray<string>> = [];
5532 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
5533
5534 yield* service.setFilesViewed({
5535 ...GITLAB_REFERENCE,
5536 files: [{ path: "src/a.ts", viewed: true }],
5537 });
5538 // Past the marks' own cache, so this read reaches the point where the host would be asked.
5539 yield* TestClock.adjust("20 seconds");
5540 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5541
5542 assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]);
5543 assert.deepStrictEqual(asked, [["src/a.ts"]]);
5544 }),
5545);
5546
5547it.effect("answers the marks from what it last heard while it asks the host again", () =>
5548 Effect.gen(function* () {
5549 const asked: Array<ReadonlyArray<string>> = [];
5550 const revisions = new Map([["src/a.ts", "blob-a"]]);
5551 const service = yield* environmentViewedService(revisions, asked);
5552
5553 yield* service.setFilesViewed({
5554 ...GITLAB_REFERENCE,
5555 files: [{ path: "src/a.ts", viewed: true }],
5556 });
5557 revisions.set("src/a.ts", "blob-a-again");
5558 yield* TestClock.adjust("90 seconds");
5559 const held = yield* service.filesViewed(GITLAB_REFERENCE);
5560
5561 // The push is not in this answer, because waiting for the host is the thing being avoided.
5562 assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]);
5563 assert.strictEqual(asked.length, 2);
5564
5565 yield* TestClock.adjust("20 seconds");
5566 const caught = yield* service.filesViewed(GITLAB_REFERENCE);
5567
5568 assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]);
5569 // The refresh behind the previous answer is the one that heard about the push.
5570 assert.strictEqual(asked.length, 2);
5571 }),
5572);
5573
5574it.effect("asks the host about a file it has not been asked about before", () =>
5575 Effect.gen(function* () {
5576 const asked: Array<ReadonlyArray<string>> = [];
5577 const service = yield* environmentViewedService(
5578 new Map([
5579 ["src/a.ts", "blob-a"],
5580 ["src/b.ts", "blob-b"],
5581 ]),
5582 asked,
5583 );
5584
5585 yield* service.setFilesViewed({
5586 ...GITLAB_REFERENCE,
5587 files: [{ path: "src/a.ts", viewed: true }],
5588 });
5589 yield* TestClock.adjust("20 seconds");
5590 yield* service.setFilesViewed({
5591 ...GITLAB_REFERENCE,
5592 files: [{ path: "src/b.ts", viewed: true }],
5593 });
5594 yield* TestClock.adjust("20 seconds");
5595 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5596
5597 assert.deepStrictEqual(
5598 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5599 [
5600 { path: "src/a.ts", state: "viewed" },
5601 { path: "src/b.ts", state: "viewed" },
5602 ],
5603 );
5604 // The second press paid for its own file; the read that follows was already covered.
5605 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
5606 }),
5607);
5608
5609it.effect("does not let a press about one file keep another file's version alive", () =>
5610 Effect.gen(function* () {
5611 const asked: Array<ReadonlyArray<string>> = [];
5612 const revisions = new Map([
5613 ["src/a.ts", "blob-a"],
5614 ["src/b.ts", "blob-b"],
5615 ]);
5616 const service = yield* environmentViewedService(revisions, asked);
5617
5618 yield* service.setFilesViewed({
5619 ...GITLAB_REFERENCE,
5620 files: [{ path: "src/a.ts", viewed: true }],
5621 });
5622 yield* TestClock.adjust("40 seconds");
5623 // This press asks about its own file and carries the other one forward untouched. Counting
5624 // the whole scope as heard from would put the first file's version back inside the window it
5625 // had almost aged out of, and a reader working down a long diff renews it press after press.
5626 yield* service.setFilesViewed({
5627 ...GITLAB_REFERENCE,
5628 files: [{ path: "src/b.ts", viewed: true }],
5629 });
5630 assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]);
5631
5632 revisions.set("src/a.ts", "blob-a-again");
5633 yield* TestClock.adjust("30 seconds");
5634 yield* service.filesViewed(GITLAB_REFERENCE);
5635 assert.strictEqual(asked.length, 3);
5636
5637 yield* TestClock.adjust("20 seconds");
5638 const caught = yield* service.filesViewed(GITLAB_REFERENCE);
5639
5640 assert.deepStrictEqual(
5641 [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5642 [
5643 { path: "src/a.ts", state: "dismissed" },
5644 { path: "src/b.ts", state: "viewed" },
5645 ],
5646 );
5647 }),
5648);
5649
5650it.effect("reports a file pushed to since it was cleared as changed", () =>
5651 Effect.gen(function* () {
5652 const revisions = new Map([
5653 ["src/a.ts", "blob-a"],
5654 ["src/b.ts", "blob-b"],
5655 ]);
5656 const service = yield* environmentViewedService(revisions, []);
5657
5658 yield* service.setFilesViewed({
5659 ...GITLAB_REFERENCE,
5660 files: [
5661 { path: "src/a.ts", viewed: true },
5662 { path: "src/b.ts", viewed: true },
5663 ],
5664 });
5665 revisions.set("src/a.ts", "blob-a-again");
5666 // A push is not something the marks can hear about, so the reader asks to be re-answered.
5667 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5668 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5669
5670 assert.deepStrictEqual(
5671 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5672 [
5673 { path: "src/a.ts", state: "dismissed" },
5674 { path: "src/b.ts", state: "viewed" },
5675 ],
5676 );
5677 }),
5678);
5679
5680it.effect("clears a mark again when the file is put back", () =>
5681 Effect.gen(function* () {
5682 const asked: Array<ReadonlyArray<string>> = [];
5683 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
5684
5685 yield* service.setFilesViewed({
5686 ...GITLAB_REFERENCE,
5687 files: [{ path: "src/a.ts", viewed: true }],
5688 });
5689 yield* service.setFilesViewed({
5690 ...GITLAB_REFERENCE,
5691 files: [{ path: "src/a.ts", viewed: false }],
5692 });
5693 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5694
5695 assert.deepStrictEqual(marked.files, []);
5696 // Unticking asks the host nothing: the row is going away whatever the head has.
5697 assert.deepStrictEqual(asked, [["src/a.ts"]]);
5698 }),
5699);
5700
5701it.effect("keeps a deleted file cleared, which the head has no version of at all", () =>
5702 Effect.gen(function* () {
5703 const service = yield* environmentViewedService(new Map(), []);
5704
5705 yield* service.setFilesViewed({
5706 ...GITLAB_REFERENCE,
5707 files: [{ path: "src/gone.ts", viewed: true }],
5708 });
5709 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5710 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5711
5712 assert.deepStrictEqual(marked.files, [{ path: "src/gone.ts", state: "viewed" }]);
5713 }),
5714);
5715
5716it.effect("leaves a mark alone when the host could not say what the head has of it", () =>
5717 Effect.gen(function* () {
5718 // A host answers for as much of a long change as it can read in one go. Reading the rest as
5719 // deleted would clear every file past the cut over a version nobody ever looked at.
5720 const revisions = new Map([
5721 ["src/a.ts", "blob-a"],
5722 ["src/past-the-cut.ts", "blob-b"],
5723 ]);
5724 const service = yield* environmentViewedService(
5725 revisions,
5726 [],
5727 new Set(["src/past-the-cut.ts"]),
5728 );
5729
5730 yield* service.setFilesViewed({
5731 ...GITLAB_REFERENCE,
5732 files: [
5733 { path: "src/a.ts", viewed: true },
5734 { path: "src/past-the-cut.ts", viewed: true },
5735 ],
5736 });
5737 revisions.set("src/a.ts", "blob-a-again");
5738 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5739 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5740
5741 assert.deepStrictEqual(
5742 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
5743 [
5744 { path: "src/a.ts", state: "dismissed" },
5745 { path: "src/past-the-cut.ts", state: "viewed" },
5746 ],
5747 );
5748 }),
5749);
5750
5751it.effect("keeps a file cleared that the press could not learn a version for", () =>
5752 Effect.gen(function* () {
5753 // The press is the only moment a mark is given something to be measured against, and a host
5754 // reading as much of a long change as it can manage does not always reach the file being
5755 // ticked. Storing the empty version there reads as the head having nothing of the file, so the
5756 // first read that does reach it reports the reader's own press back to them as work to do.
5757 const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]);
5758 const unreadable = new Set(["src/past-the-cut.ts"]);
5759 const service = yield* environmentViewedService(revisions, [], unreadable);
5760
5761 yield* service.setFilesViewed({
5762 ...GITLAB_REFERENCE,
5763 files: [{ path: "src/past-the-cut.ts", viewed: true }],
5764 });
5765 unreadable.delete("src/past-the-cut.ts");
5766 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5767
5768 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5769 { path: "src/past-the-cut.ts", state: "viewed" },
5770 ]);
5771 }),
5772);
5773
5774it.effect("keeps the version it last heard when a later read of the head stops short", () =>
5775 Effect.gen(function* () {
5776 const asked: Array<ReadonlyArray<string>> = [];
5777 const revisions = new Map([["src/a.ts", "blob-a"]]);
5778 const unreadable = new Set<string>();
5779 const service = yield* environmentViewedService(revisions, asked, unreadable);
5780
5781 yield* service.setFilesViewed({
5782 ...GITLAB_REFERENCE,
5783 files: [{ path: "src/a.ts", viewed: true }],
5784 });
5785 revisions.set("src/a.ts", "blob-a-again");
5786 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5787 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5788 { path: "src/a.ts", state: "dismissed" },
5789 ]);
5790
5791 // The read behind the next answer has to stop before this file. Forgetting the version it was
5792 // last seen at would put the badge the reader has already been shown back to cleared, over an
5793 // answer that said nothing about the file either way.
5794 unreadable.add("src/a.ts");
5795 yield* TestClock.adjust("90 seconds");
5796 yield* service.filesViewed(GITLAB_REFERENCE);
5797 yield* TestClock.adjust("20 seconds");
5798
5799 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5800 { path: "src/a.ts", state: "dismissed" },
5801 ]);
5802 }),
5803);
5804
5805it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () =>
5806 Effect.gen(function* () {
5807 const revisions = new Map([["src/a.ts", "blob-a"]]);
5808 const service = yield* environmentViewedService(revisions, []);
5809
5810 yield* service.setFilesViewed({
5811 ...GITLAB_REFERENCE,
5812 files: [{ path: "src/a.ts", viewed: true }],
5813 });
5814 // A push nobody told this environment about. No single reference has moved, so the held
5815 // answer goes only because the refresh is the reader asking for all of it to be read again.
5816 revisions.set("src/a.ts", "blob-a-again");
5817 yield* service.invalidate({});
5818
5819 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5820 { path: "src/a.ts", state: "dismissed" },
5821 ]);
5822 }),
5823);
5824
5825it.effect("forgets what the head had of a marked file once a mutation moves the head", () =>
5826 Effect.gen(function* () {
5827 const revisions = new Map([["src/a.ts", "blob-a"]]);
5828 const service = yield* environmentViewedService(revisions, []);
5829
5830 yield* service.setFilesViewed({
5831 ...GITLAB_REFERENCE,
5832 files: [{ path: "src/a.ts", viewed: true }],
5833 });
5834 // Merging moves the head under the mark, and nobody asks for the refresh: the mutation is
5835 // the thing that knows, so it drops what it was holding rather than waiting to be told.
5836 revisions.set("src/a.ts", "blob-a-again");
5837 yield* service.runAction({ ...GITLAB_REFERENCE, action: "merge" });
5838
5839 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5840 { path: "src/a.ts", state: "dismissed" },
5841 ]);
5842 }),
5843);
5844
5845it.effect("still reports its own marks when the host will not say what the head has", () =>
5846 Effect.gen(function* () {
5847 let answering = true;
5848 const service = yield* makeService({
5849 projects: [
5850 project({
5851 id: "p1",
5852 title: "on gitlab",
5853 workspaceRoot: "/a",
5854 repository: "group/project",
5855 provider: "gitlab",
5856 }),
5857 ],
5858 providers: [
5859 fakeProvider("gitlab", {
5860 capabilities: {
5861 diff: true,
5862 comment: true,
5863 actions: ["merge"],
5864 mergeMethods: ["merge"],
5865 search: true,
5866 reactions: true,
5867 viewedFiles: "environment",
5868 review: FULL_REVIEW,
5869 reviewers: FULL_REVIEWERS,
5870 },
5871 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5872 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5873 getFileRevisions: (input) =>
5874 answering
5875 ? Effect.succeed({
5876 revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)),
5877 })
5878 : Effect.fail(requestFailed),
5879 }),
5880 ],
5881 });
5882
5883 yield* service.setFilesViewed({
5884 ...GITLAB_REFERENCE,
5885 files: [{ path: "src/a.ts", viewed: true }],
5886 });
5887 answering = false;
5888 yield* service.invalidate({ reference: GITLAB_REFERENCE });
5889
5890 // The rows are this environment's own. A rate limit or a signed-out CLI costs them the
5891 // staleness they would have carried, not the reader's whole record of what they have read.
5892 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
5893 { path: "src/a.ts", state: "viewed" },
5894 ]);
5895 }),
5896);
5897
5898it.effect("finishes two presses on one file in the order they were made", () =>
5899 Effect.gen(function* () {
5900 // A tick asks the host what it has of the file before it stores anything, and an untick asks
5901 // nothing at all, so the second press would otherwise land first and be overwritten by the
5902 // first one finishing behind it.
5903 const held = yield* Deferred.make<void>();
5904 const service = yield* makeService({
5905 projects: [
5906 project({
5907 id: "p1",
5908 title: "on gitlab",
5909 workspaceRoot: "/a",
5910 repository: "group/project",
5911 provider: "gitlab",
5912 }),
5913 ],
5914 providers: [
5915 fakeProvider("gitlab", {
5916 capabilities: {
5917 diff: true,
5918 comment: true,
5919 actions: ["merge"],
5920 mergeMethods: ["merge"],
5921 search: true,
5922 reactions: true,
5923 viewedFiles: "environment",
5924 review: FULL_REVIEW,
5925 reviewers: FULL_REVIEWERS,
5926 },
5927 getFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5928 setFilesViewed: () => Effect.die("the host keeps no marks of its own"),
5929 getFileRevisions: (input) =>
5930 Deferred.await(held).pipe(
5931 Effect.as({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }),
5932 ),
5933 }),
5934 ],
5935 });
5936
5937 const tick = service
5938 .setFilesViewed({
5939 ...GITLAB_REFERENCE,
5940 files: [{ path: "src/a.ts", viewed: true }],
5941 })
5942 .pipe(Effect.runFork);
5943 // Far enough for the tick to be waiting on the host rather than still on its way there.
5944 yield* TestClock.adjust("1 second");
5945 const untick = service
5946 .setFilesViewed({
5947 ...GITLAB_REFERENCE,
5948 files: [{ path: "src/a.ts", viewed: false }],
5949 })
5950 .pipe(Effect.runFork);
5951 yield* TestClock.adjust("1 second");
5952 yield* Deferred.succeed(held, undefined);
5953 yield* Fiber.join(tick);
5954 yield* Fiber.join(untick);
5955
5956 // The untick came second and stands: the file is open again.
5957 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
5958 assert.deepStrictEqual(marked.files, []);
5959 }),
5960);
5961
5962/** Azure, whose selector is a bare repository name, backed by this environment's own marks. */
5963const azureViewedService = (projects: ReadonlyArray<OrchestrationProjectShell>) =>
5964 makeService({
5965 projects,
5966 providers: [
5967 fakeProvider("azure-devops", {
5968 capabilities: {
5969 diff: true,
5970 comment: true,
5971 actions: ["merge"],
5972 mergeMethods: ["merge"],
5973 search: true,
5974 reactions: true,
5975 viewedFiles: "environment",
5976 review: FULL_REVIEW,
5977 reviewers: FULL_REVIEWERS,
5978 },
5979 getFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"),
5980 setFilesViewed: () => Effect.die("the host keeps no marks of this environment's own"),
5981 getFileRevisions: (input) =>
5982 Effect.succeed({ revisions: new Map(input.paths.map((path) => [path, "blob-a"])) }),
5983 }),
5984 ],
5985 });
5986
5987const AZURE_PAIR = [
5988 project({
5989 id: "p1",
5990 title: "platform web",
5991 workspaceRoot: "/a",
5992 repository: "acme/platform/_git/web",
5993 provider: "azure-devops",
5994 host: "dev.azure.com",
5995 }),
5996 project({
5997 id: "p2",
5998 title: "other web",
5999 workspaceRoot: "/b",
6000 repository: "acme/other/_git/web",
6001 provider: "azure-devops",
6002 host: "dev.azure.com",
6003 }),
6004];
6005
6006const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 };
6007const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 };
6008
6009it.effect("keeps the marks of two Azure repositories of the same name apart", () =>
6010 Effect.gen(function* () {
6011 // Azure addresses a repository by its bare name, which is unique inside one of its projects
6012 // and not across an organisation. Two `web` repositories would otherwise share one row.
6013 const service = yield* azureViewedService(AZURE_PAIR);
6014
6015 yield* service.setFilesViewed({
6016 ...AZURE_PLATFORM,
6017 files: [{ path: "src/a.ts", viewed: true }],
6018 });
6019
6020 assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [
6021 { path: "src/a.ts", state: "viewed" },
6022 ]);
6023 assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []);
6024 }),
6025);
6026
6027it.effect("keeps environment marks apart from another change request's", () =>
6028 Effect.gen(function* () {
6029 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []);
6030
6031 yield* service.setFilesViewed({
6032 ...GITLAB_REFERENCE,
6033 files: [{ path: "src/a.ts", viewed: true }],
6034 });
6035 const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 });
6036
6037 assert.deepStrictEqual(other.files, []);
6038 }),
6039);
6040
6041it.effect("bounds the paths one change request's held revisions carry", () =>
6042 Effect.gen(function* () {
6043 // The cache's count bounds how many change requests are held, not what any one of them holds:
6044 // a reader ticking a wide change request renews the same entry on every press and adds a path
6045 // to it each time. A press carries at most half the cap, so going one past it takes three.
6046 const asked: Array<ReadonlyArray<string>> = [];
6047 const service = yield* environmentViewedService(new Map(), asked);
6048 const batch = (prefix: string, count: number) =>
6049 Array.from({ length: count }, (_, index) => ({
6050 path: `${prefix}/${String(index).padStart(4, "0")}.ts`,
6051 viewed: true,
6052 }));
6053 const press = (prefix: string, count: number) =>
6054 service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) });
6055
6056 yield* press("a", MAX_FILE_REVISION_PATHS / 2);
6057 yield* press("b", MAX_FILE_REVISION_PATHS / 2);
6058 yield* press("c", 1);
6059 const pressed = asked.length;
6060
6061 // The marks a read carries come first by path, so this one covers the earliest batch, which
6062 // is where the paths asked about longest ago are. Held short of them the entry no longer
6063 // answers the read, and the host is asked rather than the reader being told a version that
6064 // nothing holds any more.
6065 yield* service.filesViewed(GITLAB_REFERENCE);
6066
6067 assert.strictEqual(asked.length, pressed + 1);
6068 assert.ok(asked.at(-1)?.includes("a/0000.ts"));
6069 }),
6070);
6071
6072it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () =>
6073 Effect.gen(function* () {
6074 // A host with no per-file version answers with the whole change, which on a wide review
6075 // carries more paths than one entry holds. What the trim reaches has to be the paths the
6076 // answer threw in rather than the ones the reader ticked: a mark stored with no baseline
6077 // reports viewed however far the head moves off it.
6078 const head = new Map<string, string>(
6079 Array.from(
6080 { length: MAX_FILE_REVISION_PATHS + 178 },
6081 (_, index) =>
6082 [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const,
6083 ),
6084 );
6085 const service = yield* makeService({
6086 projects: [
6087 project({
6088 id: "p1",
6089 title: "on gitlab",
6090 workspaceRoot: "/a",
6091 repository: "group/project",
6092 provider: "gitlab",
6093 }),
6094 ],
6095 providers: [
6096 {
6097 ...environmentViewedProvider(head, []),
6098 getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }),
6099 },
6100 ],
6101 });
6102 const ticked = ["src/f0000.ts", "src/f0500.ts"];
6103
6104 yield* service.setFilesViewed({
6105 ...GITLAB_REFERENCE,
6106 files: ticked.map((path) => ({ path, viewed: true })),
6107 });
6108 for (const path of ticked) head.set(path, "blob-moved");
6109 // Past the stale window, so the read is answered by the host rather than from what the press
6110 // heard.
6111 yield* TestClock.adjust("11 minutes");
6112 const marked = yield* service.filesViewed(GITLAB_REFERENCE);
6113
6114 assert.deepStrictEqual(
6115 [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)),
6116 [
6117 { path: "src/f0000.ts", state: "dismissed" },
6118 { path: "src/f0500.ts", state: "dismissed" },
6119 ],
6120 );
6121 }),
6122);
6123
6124it.effect("keeps the change request being ticked through, not the one pressed first", () =>
6125 Effect.gen(function* () {
6126 // Ordered by insertion alone a hit does not renew its entry, so the review a reader is
6127 // working down is the first thing dropped once a cache's worth of other change requests have
6128 // been pressed, and the next press on it pays a host read for a version already held.
6129 const asked: Array<ReadonlyArray<string>> = [];
6130 const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked);
6131 const press = (number: number) =>
6132 service.setFilesViewed({
6133 ...GITLAB_REFERENCE,
6134 number,
6135 files: [{ path: "src/a.ts", viewed: true }],
6136 });
6137
6138 yield* press(1);
6139 // A cache's worth of other change requests, with the open one pressed in between each.
6140 for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) {
6141 yield* press(2 + filled);
6142 yield* press(1);
6143 }
6144
6145 assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY);
6146 }),
6147);
6148
6149/** The environment-backed fixture with its own answer to who the reader is. */
6150const environmentViewedServiceWithViewer = (
6151 revisions: Map<string, string>,
6152 getViewer: PullRequestProviderApi["getViewer"],
6153) =>
6154 makeService({
6155 projects: [
6156 project({
6157 id: "p1",
6158 title: "on gitlab",
6159 workspaceRoot: "/a",
6160 repository: "group/project",
6161 provider: "gitlab",
6162 }),
6163 ],
6164 providers: [{ ...environmentViewedProvider(revisions, []), getViewer }],
6165 });
6166
6167it.effect("keeps one reader's marks on a host that names nobody", () =>
6168 Effect.gen(function* () {
6169 const service = yield* environmentViewedServiceWithViewer(
6170 new Map([["src/a.ts", "blob-a"]]),
6171 () => Effect.succeed(""),
6172 );
6173
6174 yield* service.setFilesViewed({
6175 ...GITLAB_REFERENCE,
6176 files: [{ path: "src/a.ts", viewed: true }],
6177 });
6178
6179 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
6180 { path: "src/a.ts", state: "viewed" },
6181 ]);
6182 }),
6183);
6184
6185it.effect("puts a listing and a press for one host on a single viewer lookup", () =>
6186 Effect.gen(function* () {
6187 let viewerLookups = 0;
6188 const service = yield* makeService({
6189 projects: [
6190 project({
6191 id: "p1",
6192 title: "on gitlab",
6193 workspaceRoot: "/a",
6194 repository: "group/project",
6195 provider: "gitlab",
6196 }),
6197 ],
6198 providers: [
6199 {
6200 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
6201 getViewer: () =>
6202 Effect.gen(function* () {
6203 viewerLookups += 1;
6204 // Suspends before answering, as a subprocess would, so both callers are in flight
6205 // at once rather than the second finding the first has already answered.
6206 yield* Effect.yieldNow;
6207 return "bilal";
6208 }),
6209 },
6210 ],
6211 });
6212
6213 // What a cold page load does: read the listing and the reader's own marks at the same time.
6214 // Nothing about which of them asked is in the lookup's key, so they wait on one CLI between
6215 // them rather than starting one each.
6216 yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], {
6217 concurrency: 2,
6218 });
6219
6220 assert.strictEqual(viewerLookups, 1);
6221 }),
6222);
6223
6224it.effect("carries a bounded number of its own marks and says it held more", () =>
6225 Effect.gen(function* () {
6226 const asked: Array<ReadonlyArray<string>> = [];
6227 const paths = Array.from(
6228 { length: PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS + 40 },
6229 (_, at) => `src/f${String(at).padStart(4, "0")}.ts`,
6230 );
6231 const service = yield* environmentViewedService(
6232 new Map(paths.map((path) => [path, "blob"] as const)),
6233 asked,
6234 );
6235
6236 yield* service.setFilesViewed({
6237 ...GITLAB_REFERENCE,
6238 files: paths.map((path) => ({ path, viewed: true })),
6239 });
6240 const read = yield* service.filesViewed(GITLAB_REFERENCE);
6241
6242 // Every mark read is a path held in a set and a map for as long as the caller holds the read,
6243 // per scope it is holding, so the rows are bounded rather than however many a reader has ever
6244 // ticked. The reader is told the count is short rather than shown a quietly clipped list.
6245 assert.lengthOf(read.files, PullRequestFilesViewed.MAX_FILES_VIEWED_ROWS);
6246 assert.strictEqual(read.truncated, true);
6247 }),
6248);
6249
6250it.effect("records a press while the host is backing off", () =>
6251 Effect.gen(function* () {
6252 let viewerLookups = 0;
6253 const service = yield* makeService({
6254 projects: [
6255 project({
6256 id: "p1",
6257 title: "on gitlab",
6258 workspaceRoot: "/a",
6259 repository: "group/project",
6260 provider: "gitlab",
6261 }),
6262 ],
6263 providers: [
6264 {
6265 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
6266 getViewer: () =>
6267 Effect.sync(() => {
6268 viewerLookups += 1;
6269 return "bilal";
6270 }),
6271 // Backing off for the hour, so the pause outlives the ten minutes who is signed in is
6272 // held for and the second press has to ask again while it is on.
6273 getFileRevisions: () =>
6274 Effect.fail(
6275 new PullRequestProviderError({
6276 provider: "gitlab",
6277 operation: "getFileRevisions",
6278 reason: "rate-limited",
6279 detail: "API rate limit exceeded.",
6280 retryAt: 60 * 60 * 1_000,
6281 }),
6282 ),
6283 },
6284 ],
6285 });
6286
6287 yield* service.setFilesViewed({
6288 ...GITLAB_REFERENCE,
6289 files: [{ path: "src/a.ts", viewed: true }],
6290 });
6291 yield* TestClock.adjust("11 minutes");
6292 yield* service.setFilesViewed({
6293 ...GITLAB_REFERENCE,
6294 files: [{ path: "src/b.ts", viewed: true }],
6295 });
6296
6297 // Nothing about a host holding its reads off says the reader did not press these, and these
6298 // rows are this environment's own. They keep no baseline, because none was read, so they hold
6299 // until they are pressed again rather than reporting a staleness nobody looked up.
6300 assert.deepStrictEqual(
6301 [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) =>
6302 left.path.localeCompare(right.path),
6303 ),
6304 [
6305 { path: "src/a.ts", state: "viewed" },
6306 { path: "src/b.ts", state: "viewed" },
6307 ],
6308 );
6309 assert.strictEqual(viewerLookups, 2);
6310 }),
6311);
6312
6313it.effect("asks who is reading through a pause only for the press that is waiting on it", () =>
6314 Effect.gen(function* () {
6315 let viewerLookups = 0;
6316 const service = yield* makeService({
6317 projects: [
6318 project({
6319 id: "p1",
6320 title: "on gitlab",
6321 workspaceRoot: "/a",
6322 repository: "group/project",
6323 provider: "gitlab",
6324 }),
6325 ],
6326 providers: [
6327 {
6328 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
6329 getViewer: () =>
6330 Effect.sync(() => {
6331 viewerLookups += 1;
6332 return "bilal";
6333 }),
6334 listChangeRequests: () =>
6335 Effect.succeed({ items: [], truncated: false, continues: true }),
6336 // Backing off for the hour, so the pause outlives the ten minutes who is signed in is
6337 // held for.
6338 getFileRevisions: () =>
6339 Effect.fail(
6340 new PullRequestProviderError({
6341 provider: "gitlab",
6342 operation: "getFileRevisions",
6343 reason: "rate-limited",
6344 detail: "API rate limit exceeded.",
6345 retryAt: 60 * 60 * 1_000,
6346 }),
6347 ),
6348 },
6349 ],
6350 });
6351
6352 yield* service.setFilesViewed({
6353 ...GITLAB_REFERENCE,
6354 files: [{ path: "src/a.ts", viewed: true }],
6355 });
6356 assert.strictEqual(viewerLookups, 1);
6357 yield* TestClock.adjust("11 minutes");
6358
6359 // A listing is not the reader waiting on this lookup, and a failed one is held nowhere, so
6360 // letting it through would spawn the host's CLI on every refresh for as long as the pause
6361 // lasts and re-extend it each time.
6362 const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
6363 assert.strictEqual(listed._tag, "PullRequestOperationError");
6364 assert.strictEqual(viewerLookups, 1);
6365
6366 // The press is bounded by what the reader does, and its rows are keyed by who they are, so
6367 // it is asked rather than refused.
6368 yield* service.setFilesViewed({
6369 ...GITLAB_REFERENCE,
6370 files: [{ path: "src/b.ts", viewed: true }],
6371 });
6372 assert.strictEqual(viewerLookups, 2);
6373 }),
6374);
6375
6376it.effect("does not ask a paused host who is reading again after the ask failed", () =>
6377 Effect.gen(function* () {
6378 let viewerLookups = 0;
6379 const service = yield* makeService({
6380 projects: [
6381 project({
6382 id: "p1",
6383 title: "on gitlab",
6384 workspaceRoot: "/a",
6385 repository: "group/project",
6386 provider: "gitlab",
6387 }),
6388 ],
6389 providers: [
6390 {
6391 ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []),
6392 getViewer: () =>
6393 Effect.suspend(() => {
6394 viewerLookups += 1;
6395 return Effect.fail(
6396 new PullRequestProviderError({
6397 provider: "gitlab",
6398 operation: "getViewer",
6399 reason: "failed",
6400 detail: "glab exited with status 1",
6401 }),
6402 );
6403 }),
6404 listChangeRequests: () =>
6405 Effect.succeed({ items: [], truncated: false, continues: true }),
6406 runAction: () =>
6407 Effect.fail(
6408 new PullRequestProviderError({
6409 provider: "gitlab",
6410 operation: "runAction",
6411 reason: "rate-limited",
6412 detail: "API rate limit exceeded.",
6413 retryAt: 60 * 60 * 1_000,
6414 }),
6415 ),
6416 },
6417 ],
6418 });
6419
6420 yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE));
6421 assert.strictEqual(viewerLookups, 1);
6422 yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" }));
6423
6424 // A failed lookup is held nowhere, so a background read let through the pause would spawn the
6425 // host's CLI on every refresh for as long as the pause lasted, and re-extend it each time.
6426 yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
6427 yield* TestClock.adjust("11 minutes");
6428 yield* Effect.flip(service.list({ state: "open", involvement: "all" }));
6429 assert.strictEqual(viewerLookups, 1);
6430 }),
6431);
6432
6433it.effect("refuses the marks when the host could not be asked who is reading", () =>
6434 Effect.gen(function* () {
6435 let answering = true;
6436 const service = yield* environmentViewedServiceWithViewer(
6437 new Map([["src/a.ts", "blob-a"]]),
6438 () =>
6439 answering
6440 ? Effect.succeed("bilal")
6441 : Effect.fail(
6442 new PullRequestProviderError({
6443 provider: "gitlab",
6444 operation: "getViewer",
6445 reason: "failed",
6446 detail: "glab exited with status 1",
6447 }),
6448 ),
6449 );
6450
6451 yield* service.setFilesViewed({
6452 ...GITLAB_REFERENCE,
6453 files: [{ path: "src/a.ts", viewed: true }],
6454 });
6455 // Who is signed in is held for ten minutes, so the lookup has to come round again before a
6456 // failing CLI can reach the read at all.
6457 answering = false;
6458 yield* TestClock.adjust("11 minutes");
6459
6460 // Answering these from the unnamed reader's rows would show the reader none of their own
6461 // ticks, and file the next press where the recovered CLI will never look for it again.
6462 const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE));
6463 const write = yield* Effect.flip(
6464 service.setFilesViewed({
6465 ...GITLAB_REFERENCE,
6466 files: [{ path: "src/b.ts", viewed: true }],
6467 }),
6468 );
6469 assert.strictEqual(read._tag, "PullRequestOperationError");
6470 assert.strictEqual(write._tag, "PullRequestOperationError");
6471
6472 answering = true;
6473 assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [
6474 { path: "src/a.ts", state: "viewed" },
6475 ]);
6476 }),
6477);
6478
6479it.effect("refuses to track viewed files on a host that does not", () =>
6480 Effect.gen(function* () {
6481 const service = yield* makeService({
6482 projects: [
6483 project({
6484 id: "p1",
6485 title: "on gitlab",
6486 workspaceRoot: "/a",
6487 repository: "group/project",
6488 provider: "gitlab",
6489 }),
6490 ],
6491 providers: [
6492 fakeProvider("gitlab", {
6493 getFilesViewed: () => Effect.die("must not be called"),
6494 setFilesViewed: () => Effect.die("must not be called"),
6495 }),
6496 ],
6497 });
6498 const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 };
6499
6500 const read = yield* Effect.flip(service.filesViewed(reference));
6501 const write = yield* Effect.flip(
6502 service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }),
6503 );
6504
6505 assert.strictEqual(read._tag, "PullRequestOperationError");
6506 assert.strictEqual(write._tag, "PullRequestOperationError");
6507 }),
6508);
6509
50926510it.effect("keeps Azure continuation cursors separate for repositories with the same name", () =>
50936511 Effect.gen(function* () {
50946512 const seen: string[] = [];
apps/server/src/pullRequest/PullRequestService.ts+177-12
......@@ -3,6 +3,7 @@ import {
33 isSshRemoteUrl,
44 sourceControlRepositorySelector,
55} from "@t3tools/shared/sourceControl";
6import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
67import * as Cache from "effect/Cache";
78import * as Clock from "effect/Clock";
89import * as Context from "effect/Context";
......@@ -35,6 +36,7 @@ import {
3536 type PullRequestDiffFileContentsResult,
3637 type PullRequestDiffStat,
3738 type PullRequestDiffInput,
39 type PullRequestFilesViewedResult,
3840 type PullRequestDiffResult,
3941 type PullRequestInvalidateInput,
4042 type PullRequestListEntry,
......@@ -55,6 +57,7 @@ import {
5557 type PullRequestReviewerRequestInput,
5658 type PullRequestLabelCandidateList,
5759 type PullRequestLabelChangeInput,
60 type PullRequestSetFilesViewedInput,
5861 type PullRequestSubmitReviewInput,
5962 PullRequestStack,
6063 PullRequestSummary,
......@@ -70,6 +73,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source
7073
7174import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts";
7275import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
76import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
7377import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
7478import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
7579import {
......@@ -80,6 +84,7 @@ import {
8084} from "./PullRequestProvider.ts";
8185import * as PullRequestReadCache from "./PullRequestReadCache.ts";
8286import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";
87import * as ViewedFiles from "./pullRequestViewedFiles.ts";
8388
8489export interface PullRequestMergeEvent extends PullRequestRef {
8590 readonly mergedAt: string;
......@@ -128,6 +133,12 @@ const DIFF_CACHE_TTL = Duration.seconds(60);
128133const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10);
129134/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */
130135const LIST_STATS_CACHE_TTL = Duration.seconds(60);
136/**
137 * Short, and with no stale window behind it: this is the reader's own bookkeeping, and the
138 * press that changes it is the same press the page is already showing optimistically. Held at
139 * all only so opening a change request on two devices costs one read.
140 */
141const FILES_VIEWED_CACHE_TTL = Duration.seconds(15);
131142/** A diff can stay interactive while its next cached value is fetched off the critical path. */
132143const DIFF_STALE_WINDOW = Duration.minutes(10);
133144/** How long one host's signed-in login is believed without asking its CLI again. */
......@@ -139,6 +150,7 @@ const LIST_CACHE_CAPACITY = 64;
139150const LIST_STATS_CACHE_CAPACITY = 32;
140151const DETAIL_CACHE_CAPACITY = 128;
141152const DIFF_CACHE_CAPACITY = 128;
153const FILES_VIEWED_CACHE_CAPACITY = 128;
142154const VIEWER_CACHE_CAPACITY = 32;
143155
144156export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;
......@@ -202,6 +214,12 @@ export class PullRequestService extends Context.Service<
202214 readonly diffFileContents: (
203215 input: PullRequestDiffFileContentsInput,
204216 ) => Effect.Effect<PullRequestDiffFileContentsResult, PullRequestError>;
217 readonly filesViewed: (
218 input: PullRequestRef,
219 ) => Effect.Effect<PullRequestFilesViewedResult, PullRequestError>;
220 readonly setFilesViewed: (
221 input: PullRequestSetFilesViewedInput,
222 ) => Effect.Effect<void, PullRequestError>;
205223 readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>;
206224 readonly update: (input: PullRequestUpdateInput) => Effect.Effect<void, PullRequestError>;
207225 readonly comment: (input: PullRequestCommentInput) => Effect.Effect<void, PullRequestError>;
......@@ -281,13 +299,18 @@ const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to as
281299const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels.";
282300
283301/** A project this page can read: its remote is on a host with an implementation. */
284interface SupportedProject {
302export interface SupportedProject {
285303 readonly cursorKey: string;
286304 readonly project: OrchestrationProjectShell;
287305 readonly api: PullRequestProviderApi;
288306 readonly repository: string;
289307 /** The host the repository lives on, which is the account boundary rather than the kind. */
290308 readonly host: string;
309 /**
310 * The identity's canonical key, which is what this environment's own records are keyed by.
311 * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation.
312 */
313 readonly remote: string;
291314}
292315
293316/**
......@@ -448,6 +471,7 @@ function withRateLimitBackoff(
448471 api: PullRequestProviderApi,
449472 host: string,
450473 limits: SourceControlRateLimit.SourceControlRateLimit["Service"],
474 options?: { readonly viewerAllowsPause: boolean },
451475): PullRequestProviderApi {
452476 const key = { provider: api.kind, host };
453477 const protect = <A>(
......@@ -499,7 +523,13 @@ function withRateLimitBackoff(
499523 const wrapped = {
500524 kind: api.kind,
501525 capabilities: api.capabilities,
502 getViewer: wrap("getViewer", api.getViewer),
526 // Refused during a pause like any other read, except for the caller that asks for the
527 // bypass: a lookup that failed is not held, so letting every background read through would
528 // spawn this host's CLI on each of them and re-extend the pause it was already in.
529 getViewer:
530 options?.viewerAllowsPause === true
531 ? interactive("getViewer", api.getViewer)
532 : wrap("getViewer", api.getViewer),
503533 ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }),
504534 ...(api.withVerifiedCredential === undefined
505535 ? {}
......@@ -535,6 +565,15 @@ function withRateLimitBackoff(
535565 ...(api.getDiffFileContents === undefined
536566 ? {}
537567 : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }),
568 ...(api.getFilesViewed === undefined
569 ? {}
570 : { getFilesViewed: wrap("getFilesViewed", api.getFilesViewed) }),
571 ...(api.setFilesViewed === undefined
572 ? {}
573 : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }),
574 ...(api.getFileRevisions === undefined
575 ? {}
576 : { getFileRevisions: wrap("getFileRevisions", api.getFileRevisions) }),
538577 runAction: interactive("runAction", api.runAction),
539578 ...(api.updateChangeRequest === undefined
540579 ? {}
......@@ -568,6 +607,7 @@ export const make = Effect.gen(function* () {
568607 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
569608 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
570609 const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit;
610 const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository;
571611 const readCache = yield* PullRequestReadCache.PullRequestReadCache;
572612
573613 const refineUnknownProjectKinds = (
......@@ -727,6 +767,10 @@ export const make = Effect.gen(function* () {
727767 api: withRateLimitBackoff(api, host, rateLimits),
728768 repository,
729769 host,
770 remote:
771 kind === "azure-devops"
772 ? identity.canonicalKey
773 : normalizeGitRemoteUrl(`https://${host}/${repository}`),
730774 });
731775 }
732776 return { supported, unimplemented, viewerRoots };
......@@ -793,7 +837,14 @@ export const make = Effect.gen(function* () {
793837 );
794838 }
795839 return Effect.succeed(
796 route.api.kind === "azure-devops" ? route : { ...route, repository },
840 route.api.kind === "azure-devops" ||
841 route.repository.toLowerCase() === repository.toLowerCase()
842 ? route
843 : {
844 ...route,
845 repository,
846 remote: normalizeGitRemoteUrl(`https://${host}/${repository}`),
847 },
797848 );
798849 }),
799850 );
......@@ -890,7 +941,10 @@ export const make = Effect.gen(function* () {
890941 if (registered === null) {
891942 return Effect.die(new Error(`Missing pull request provider: ${kind}`));
892943 }
893 const api = withRateLimitBackoff(registered, host, rateLimits);
944 // Let through a pause: a press the reader is waiting on has to be answered, and the
945 // callers that are not that press are held back at the gate below instead, before they
946 // reach this lookup at all.
947 const api = withRateLimitBackoff(registered, host, rateLimits, { viewerAllowsPause: true });
894948 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe(
895949 Effect.map((viewer) => ({
896950 host,
......@@ -923,6 +977,7 @@ export const make = Effect.gen(function* () {
923977 const resolveViewers = (
924978 projects: ReadonlyArray<SupportedProject>,
925979 viewerRoots: WorkspaceProjects["viewerRoots"],
980 options?: { readonly allowPaused: boolean },
926981 ) =>
927982 Effect.forEach(
928983 [...new Set(projects.map(({ host }) => host))],
......@@ -938,8 +993,33 @@ export const make = Effect.gen(function* () {
938993 // unreadable worktree would otherwise report the whole host as signed out.
939994 const roots =
940995 viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot);
996 // Nothing about the caller is in the key. A listing and a press for the same host and
997 // roots are the same lookup, and putting them on separate flights would spawn two of
998 // this host's CLIs on a cold page load, which is the coalescing this exists for.
941999 const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]);
942 return Cache.get(viewerFlights, key);
1000 if (options?.allowPaused === true) return Cache.get(viewerFlights, key);
1001 // The pause is checked here rather than inside the lookup, so that it holds back the
1002 // callers nobody is waiting on without splitting the flight they share with a press.
1003 // A failed lookup is held nowhere, so letting a background read through would spawn
1004 // this host's CLI on every refresh for as long as the pause lasted, and re-extend it.
1005 return rateLimits.check({ provider: api.kind, host }).pipe(
1006 Effect.flatMap(() => Cache.get(viewerFlights, key)),
1007 Effect.catch((error) =>
1008 Effect.succeed<ResolvedViewer>({
1009 host,
1010 kind: api.kind,
1011 viewer: null,
1012 error: new PullRequestProviderError({
1013 provider: api.kind,
1014 operation: "getViewer",
1015 reason: "rate-limited",
1016 detail: error.detail,
1017 retryAt: error.retryAt,
1018 cause: error,
1019 }),
1020 }),
1021 ),
1022 );
9431023 }),
9441024 { concurrency: REPOSITORY_CONCURRENCY },
9451025 );
......@@ -1697,6 +1777,43 @@ export const make = Effect.gen(function* () {
16971777 }),
16981778 );
16991779
1780 const context = yield* Effect.context<never>();
1781 /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */
1782 const runFork = Effect.runForkWith(context);
1783
1784 /**
1785 * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that
1786 * failed is refused rather than answered as the unnamed reader: a momentarily signed-out CLI
1787 * would otherwise hide every tick this reader has made and file the next press under rows that
1788 * are orphaned once it recovers. The reader is waiting on every one of these paths, so the
1789 * lookup is let through a host's backoff rather than turning a pause into a refusal, and only
1790 * here, where the bypass is bounded by what the reader does.
1791 */
1792 const requiredViewerOf = (
1793 project: SupportedProject,
1794 operation: string,
1795 ): Effect.Effect<string | null, PullRequestError> =>
1796 resolveViewers([project], new Map(), { allowPaused: true }).pipe(
1797 Effect.flatMap(([resolved]) => {
1798 const error = resolved?.error ?? null;
1799 return error === null
1800 ? Effect.succeed(resolved?.viewer ?? null)
1801 : Effect.fail(toPullRequestError(operation)(error));
1802 }),
1803 );
1804
1805 const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) =>
1806 canonicalRef(input).pipe(
1807 Effect.flatMap((ref) =>
1808 viewedFiles.setFilesViewed(input).pipe(
1809 // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the
1810 // change request, and dropping a 300-file diff on every checkbox is the whole cost of
1811 // the feature. Only this reader's own bookkeeping is forgotten.
1812 Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(ref))),
1813 ),
1814 ),
1815 );
1816
17001817 const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> =>
17011818 requireProject(input).pipe(
17021819 Effect.flatMap((project): Effect.Effect<string, PullRequestError> => {
......@@ -2334,8 +2451,6 @@ export const make = Effect.gen(function* () {
23342451 return { stats: stats.flat() };
23352452 });
23362453
2337 const context = yield* Effect.context<never>();
2338 const runFork = Effect.runForkWith(context);
23392454 const revalidate = <A, E>(read: Effect.Effect<A, E>) =>
23402455 Effect.context<never>().pipe(
23412456 Effect.flatMap((caller) =>
......@@ -2494,14 +2609,34 @@ export const make = Effect.gen(function* () {
24942609 if (oldest !== undefined) recentStats.delete(oldest);
24952610 }
24962611 };
2497 const bumpRefEpoch = (ref: PullRequestRef) => {
2612 const bumpEpoch = (epochs: Map<string, number>, ref: PullRequestRef) => {
24982613 const scope = refScope(ref);
2499 if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) {
2500 const oldest = refEpochs.keys().next().value;
2501 if (oldest !== undefined) refEpochs.delete(oldest);
2614 if (!epochs.has(scope) && epochs.size >= REF_EPOCH_CAPACITY) {
2615 const oldest = epochs.keys().next().value;
2616 if (oldest !== undefined) epochs.delete(oldest);
25022617 }
2503 refEpochs.set(scope, ++epochCounter);
2618 epochs.set(scope, ++epochCounter);
25042619 };
2620 const bumpRefEpoch = (ref: PullRequestRef) => bumpEpoch(refEpochs, ref);
2621 // Its own scope, so a press forgets the reader's ticks and nothing else. The read's key
2622 // carries both epochs, which is what makes an ordinary refresh re-ask for these too.
2623 const filesViewedEpochs = new Map<string, number>();
2624 const filesViewedEpoch = (ref: PullRequestRef) => filesViewedEpochs.get(refScope(ref)) ?? 0;
2625 const bumpFilesViewedEpoch = (ref: PullRequestRef) => bumpEpoch(filesViewedEpochs, ref);
2626
2627 /** Bumped by a whole-workspace refresh, the one drop no single reference's epoch covers. */
2628 let everyFileRevisionEpoch = 0;
2629 // Built after the epochs because it reads two of them: taken as an argument any higher,
2630 // `refEpoch` would be read while its `const` was still in its dead zone and this would throw.
2631 const viewedFiles = ViewedFiles.make({
2632 filesViewedStore,
2633 requireProject,
2634 requiredViewerOf,
2635 toPullRequestError,
2636 runFork,
2637 refEpoch,
2638 fileRevisionsEpoch: () => everyFileRevisionEpoch,
2639 });
25052640
25062641 /** The positional filter slot of a cache key, back as the record `listUncached` takes. */
25072642 const filtersOfKey = (
......@@ -2785,6 +2920,25 @@ export const make = Effect.gen(function* () {
27852920 return staleDiff(key, Cache.get(diffCache, key));
27862921 };
27872922
2923 const filesViewedCache = yield* Cache.makeWith(
2924 (key: string) => {
2925 const [referenceKey] = JSON.parse(key) as [string, number];
2926 return viewedFiles.filesViewed(refOfCacheKey(referenceKey));
2927 },
2928 {
2929 capacity: FILES_VIEWED_CACHE_CAPACITY,
2930 timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero),
2931 },
2932 );
2933 // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling,
2934 // so a reference keyed as the client spelled it would never see a refresh or a press.
2935 const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) =>
2936 canonicalRef(input).pipe(
2937 Effect.flatMap((ref) =>
2938 Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])),
2939 ),
2940 );
2941
27882942 const listStatsCache = yield* Cache.makeWith(
27892943 (key: string) => {
27902944 const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number, number]>];
......@@ -2846,6 +3000,14 @@ export const make = Effect.gen(function* () {
28463000 "PullRequestService.invalidate",
28473001 )(function* (input, options) {
28483002 const reference = input.reference;
3003 if (input.filesViewedOnly === true) {
3004 return yield* reference === undefined
3005 ? Cache.invalidateAll(filesViewedCache)
3006 : canonicalRef(reference).pipe(
3007 Effect.flatMap((ref) => Effect.sync(() => bumpFilesViewedEpoch(ref))),
3008 Effect.ignore,
3009 );
3010 }
28493011 if (reference !== undefined) {
28503012 yield* canonicalRef(reference).pipe(
28513013 Effect.flatMap((ref) =>
......@@ -2857,6 +3019,7 @@ export const make = Effect.gen(function* () {
28573019 );
28583020 } else {
28593021 listingsEpoch = ++epochCounter;
3022 everyFileRevisionEpoch = ++epochCounter;
28603023 viewersByHost.clear();
28613024 yield* Cache.invalidateAll(viewerFlights);
28623025 }
......@@ -2971,6 +3134,8 @@ export const make = Effect.gen(function* () {
29713134 threadComments,
29723135 diff: credentialCached(diff),
29733136 diffFileContents,
3137 filesViewed: credentialCached(filesViewed),
3138 setFilesViewed,
29743139 runAction: runActionAndInvalidate,
29753140 update: invalidatedByMutation(update),
29763141 comment: invalidatedByMutation(comment),
apps/server/src/pullRequest/azureDevOpsDiff.test.ts created+459
......@@ -0,0 +1,459 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 azureDevOpsFilePatch,
6 azureDevOpsUnreadableFilePatch,
7 byteLength,
8 formatAzureDevOpsDiffCursor,
9 MAX_FILE_DIFF_EDITS,
10 parseAzureDevOpsDiffCursor,
11} from "./azureDevOpsDiff.ts";
12import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
13
14function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry {
15 return {
16 path: "README.md",
17 oldPath: "README.md",
18 changeKind: "change",
19 objectId: "8f80",
20 originalObjectId: "0ca4",
21 ...overrides,
22 };
23}
24
25function texts(oldContents: string, newContents: string, binary = false) {
26 return { oldContents, newContents, binary };
27}
28
29describe("azureDevOpsFilePatch", () => {
30 it("writes a changed file as the unified patch every diff viewer already reads", () => {
31 const patch = azureDevOpsFilePatch({
32 change: change(),
33 texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"),
34 });
35
36 expect(patch.truncated).toBe(false);
37 expect(patch.section).toBe(
38 [
39 "diff --git a/README.md b/README.md",
40 "--- a/README.md",
41 "+++ b/README.md",
42 "@@ -1,3 +1,3 @@",
43 " one",
44 "-two",
45 "+two again",
46 " three",
47 "",
48 ].join("\n"),
49 );
50 });
51
52 it("names the side a new file does not have as /dev/null", () => {
53 const patch = azureDevOpsFilePatch({
54 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
55 texts: texts("", "hello\n"),
56 });
57
58 expect(patch.section).toContain("new file mode 100644");
59 expect(patch.section).toContain("--- /dev/null");
60 expect(patch.section).toContain("+++ b/DEMO.md");
61 // Git points the range a new file does not have at line zero, not at line one.
62 expect(patch.section).toContain("@@ -0,0 +1 @@");
63 expect(patch.section).toContain("+hello");
64 });
65
66 it("names the side a deleted file no longer has as /dev/null", () => {
67 const patch = azureDevOpsFilePatch({
68 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
69 texts: texts("gone\n", ""),
70 });
71
72 expect(patch.section).toContain("deleted file mode 100644");
73 expect(patch.section).toContain("--- a/OLD.md");
74 expect(patch.section).toContain("+++ /dev/null");
75 expect(patch.section).toContain("@@ -1 +0,0 @@");
76 expect(patch.section).toContain("-gone");
77 });
78
79 it("keeps the carriage returns of a file with Windows line endings", () => {
80 // They are part of the line rather than around it, so a patch that dropped them would ask
81 // the reader to look at a change that is not the one on the host.
82 const patch = azureDevOpsFilePatch({
83 change: change(),
84 texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"),
85 });
86
87 expect(patch.section).toContain("-two\r");
88 expect(patch.section).toContain("+two again\r");
89 });
90
91 it("keeps a file that only moved, which has no hunks to give", () => {
92 const patch = azureDevOpsFilePatch({
93 change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }),
94 texts: texts("same\n", "same\n"),
95 });
96
97 expect(patch.truncated).toBe(false);
98 expect(patch.section).toBe(
99 [
100 "diff --git a/docs/old.md b/docs/new.md",
101 "rename from docs/old.md",
102 "rename to docs/new.md",
103 "--- a/docs/old.md",
104 "+++ b/docs/new.md",
105 "",
106 ].join("\n"),
107 );
108 });
109
110 it("reports a binary file as changed rather than spelling it out", () => {
111 const patch = azureDevOpsFilePatch({
112 change: change({ path: "logo.png", oldPath: "logo.png" }),
113 texts: texts("PNG\u0000old", "PNG\u0000new"),
114 });
115
116 expect(patch.truncated).toBe(true);
117 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
118 });
119
120 it("shows an overlong file as changed without its hunks", () => {
121 const patch = azureDevOpsFilePatch({
122 change: change({ path: "bundle.js", oldPath: "bundle.js" }),
123 texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)),
124 });
125
126 expect(patch.truncated).toBe(true);
127 expect(patch.section).toBe(
128 ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"),
129 );
130 });
131
132 it("takes the host's word that a file is binary, whatever its bytes look like", () => {
133 // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away.
134 const patch = azureDevOpsFilePatch({
135 change: change({ path: "logo.png", oldPath: "logo.png" }),
136 texts: texts("b2xk", "bmV3", true),
137 });
138
139 expect(patch.truncated).toBe(true);
140 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
141 });
142
143 it("counts an overlong file in bytes rather than in characters", () => {
144 // Three bytes each, so a ceiling counted in code units would let three times the size through.
145 const patch = azureDevOpsFilePatch({
146 change: change({ path: "notes.md", oldPath: "notes.md" }),
147 texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)),
148 });
149
150 expect(patch.truncated).toBe(true);
151 expect(patch.section).toBe(
152 ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"),
153 );
154 });
155
156 const lineRange = (count: number, prefix: string) =>
157 Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n");
158
159 it("lists a file too far apart to diff without its hunks", () => {
160 // Sharing no line at all costs one edit per line on each side, so this pair is twice the
161 // ceiling apart. Left to itself the search costs about the square of that and would hold the
162 // whole server, every websocket client with it, while it worked out a patch nobody reads.
163 // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance
164 // rather than a proportion, so a long file reaches it having changed in one corner, and that
165 // corner would be buried in a wall of red and green.
166 const lines = (prefix: string) =>
167 Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n");
168 const patch = azureDevOpsFilePatch({
169 change: change({ path: "generated.ts", oldPath: "generated.ts" }),
170 texts: texts(`${lines("old")}\n`, `${lines("new")}\n`),
171 });
172
173 expect(patch.truncated).toBe(true);
174 // And it says the search was given up on, because the reader of a run of files is meant to
175 // stop rather than spend that work again on each of the ones behind it.
176 expect(patch.abandoned).toBe(true);
177 expect(patch.section).toBe(
178 [
179 "diff --git a/generated.ts b/generated.ts",
180 "--- a/generated.ts",
181 "+++ b/generated.ts",
182 "",
183 ].join("\n"),
184 );
185 });
186
187 it("writes out a wholly new file however many lines it has", () => {
188 // Nothing on the old side means there was no edit distance to search out, so this is the
189 // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the
190 // shape this used to lose: inside the byte ceiling that gates every file, and well past every
191 // bound a two-sided file answers to, none of which is protecting against anything here.
192 const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`;
193 const patch = azureDevOpsFilePatch({
194 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
195 texts: texts("", contents),
196 });
197
198 expect(byteLength(contents)).toBeLessThan(512 * 1024);
199 expect(patch.truncated).toBe(false);
200 expect(patch.abandoned).toBe(false);
201 expect(patch.edits).toBe(15_000);
202 expect(patch.section).toContain("@@ -0,0 +1,15000 @@");
203 // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of
204 // the middle.
205 expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001);
206 });
207
208 it("keeps a wholly new file too heavy to write out listed without its hunks", () => {
209 // Every line carries a marker, so a side of very short lines answers with up to twice its own
210 // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh
211 // about 600KB written out, more than twice what a whole slice may carry. There is no smaller
212 // true patch for a creation to fall back to, so it is listed without its hunks, the same as a
213 // side too big to read at all, rather than sent at a size the slice budget exists to prevent.
214 const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`;
215 const patch = azureDevOpsFilePatch({
216 change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }),
217 texts: texts("", contents),
218 });
219
220 expect(byteLength(contents)).toBeLessThan(512 * 1024);
221 expect(patch.truncated).toBe(true);
222 expect(patch.abandoned).toBe(false);
223 // It still cost the walk over its lines, which is what the slice is charged for.
224 expect(patch.edits).toBe(200_000);
225 expect(patch.section).toBe(
226 [
227 "diff --git a/bundle.min.js b/bundle.min.js",
228 "new file mode 100644",
229 "--- /dev/null",
230 "+++ b/bundle.min.js",
231 "",
232 ].join("\n"),
233 );
234 });
235
236 it("writes out a wholly deleted file however many lines it had", () => {
237 const contents = `${lineRange(20_000, "line")}\n`;
238 const patch = azureDevOpsFilePatch({
239 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
240 texts: texts(contents, ""),
241 });
242
243 expect(patch.truncated).toBe(false);
244 expect(patch.abandoned).toBe(false);
245 expect(patch.edits).toBe(20_000);
246 expect(patch.section).toContain("@@ -1,20000 +0,0 @@");
247 expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000);
248 expect(patch.section.match(/^-/gmu)).toHaveLength(20_001);
249 });
250
251 it("gives an empty new file no hunk to read", () => {
252 // A file with nothing on either side has no lines to claim were replaced, and git writes it
253 // as a header alone.
254 const patch = azureDevOpsFilePatch({
255 change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }),
256 texts: texts("", ""),
257 });
258
259 expect(patch.edits).toBe(0);
260 expect(patch.section).not.toContain("@@");
261 });
262
263 it("marks a wholly new file whose last line has no newline after it", () => {
264 const patch = azureDevOpsFilePatch({
265 change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }),
266 texts: texts("", "one\ntwo"),
267 });
268
269 expect(patch.section).toBe(
270 [
271 "diff --git a/NOTES.md b/NOTES.md",
272 "new file mode 100644",
273 "--- /dev/null",
274 "+++ b/NOTES.md",
275 "@@ -0,0 +1,2 @@",
276 "+one",
277 "+two",
278 "\\ No newline at end of file",
279 "",
280 ].join("\n"),
281 );
282 });
283
284 it("keeps a file it did diff out of the giving up", () => {
285 const patch = azureDevOpsFilePatch({
286 change: change(),
287 texts: texts("one\ntwo\n", "one\ntwo again\n"),
288 });
289
290 expect(patch.abandoned).toBe(false);
291 });
292
293 it("counts what the diff worked out, which is what the file cost to diff", () => {
294 // The caller spends a budget of these across a slice, so they have to be the edits the search
295 // actually made: one line replaced is a removal and an addition, and the three lines of
296 // context around them cost nothing.
297 const patch = azureDevOpsFilePatch({
298 change: change(),
299 texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"),
300 });
301
302 expect(patch.edits).toBe(2);
303 });
304
305 it("counts nothing for a file it never diffed", () => {
306 const patch = azureDevOpsFilePatch({
307 change: change({ path: "logo.png", oldPath: "logo.png" }),
308 texts: texts("PNG\u0000old", "PNG\u0000new"),
309 });
310
311 expect(patch.edits).toBe(0);
312 });
313
314 it("lists a file whose hunks outweigh its sides without them", () => {
315 // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the
316 // patch carries both sides in full with three lines of context around each hunk, so the
317 // section comes out heavier than either side was. What one file weighs is what a slice's
318 // budget is spent in, so the edit ceiling alone does not bound this.
319 const line = `${"a".repeat(400 * 1024)}\n`;
320 const patch = azureDevOpsFilePatch({
321 change: change({ path: "min.js", oldPath: "min.js" }),
322 texts: texts(line, `${"b".repeat(400 * 1024)}\n`),
323 });
324
325 expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS);
326 expect(patch.truncated).toBe(true);
327 expect(patch.section).toBe(
328 ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"),
329 );
330 expect(byteLength(patch.section)).toBeLessThan(byteLength(line));
331 });
332
333 it("marks a file that does not end in a newline, as git does", () => {
334 const patch = azureDevOpsFilePatch({
335 change: change(),
336 texts: texts("one\n", "two"),
337 });
338
339 expect(patch.section).toContain("\\ No newline at end of file");
340 });
341});
342
343describe("azureDevOpsUnreadableFilePatch", () => {
344 it("keeps a file the host would not hand over, listed without its hunks", () => {
345 const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" }));
346
347 expect(patch.truncated).toBe(true);
348 expect(patch.section).toBe(
349 ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"),
350 );
351 });
352});
353
354describe("a file Azure names something a patch header cannot carry plainly", () => {
355 it("writes each side as git's quoted form, the side letter inside the quotes", () => {
356 const patch = azureDevOpsFilePatch({
357 change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }),
358 texts: texts("one\n", "two\n"),
359 });
360
361 expect(patch.section).toBe(
362 [
363 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"',
364 '--- "a/notes\\treadme.md"',
365 '+++ "b/notes\\treadme.md"',
366 "@@ -1 +1 @@",
367 "-one",
368 "+two",
369 "",
370 ].join("\n"),
371 );
372 });
373
374 it("keeps a name holding a newline on the one header line it belongs to", () => {
375 const patch = azureDevOpsFilePatch({
376 change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }),
377 texts: texts("one\n", "two\n"),
378 });
379
380 // Written as itself the name would start a line of its own, and a reader would take what
381 // followed for a header the patch never had.
382 expect(patch.section.split("\n").slice(0, 3)).toEqual([
383 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"',
384 '--- "a/line\\nfile.txt"',
385 '+++ "b/line\\nfile.txt"',
386 ]);
387 });
388
389 it("quotes the names a rename states, which carry no side letter", () => {
390 const patch = azureDevOpsFilePatch({
391 change: change({
392 path: "docs/new\tname.md",
393 oldPath: "docs/old\tname.md",
394 changeKind: "rename-pure",
395 }),
396 texts: texts("same\n", "same\n"),
397 });
398
399 expect(patch.section).toBe(
400 [
401 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"',
402 'rename from "docs/old\\tname.md"',
403 'rename to "docs/new\\tname.md"',
404 '--- "a/docs/old\\tname.md"',
405 '+++ "b/docs/new\\tname.md"',
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("quotes the sides of the one line a binary file gets", () => {
412 const patch = azureDevOpsFilePatch({
413 change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }),
414 texts: texts("PNG\u0000old", "PNG\u0000new"),
415 });
416
417 expect(patch.section).toContain(
418 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ',
419 );
420 });
421
422 it("hands a reader of the header back the name Azure gave", () => {
423 const path = 'every\t\n"kind"\\of.md';
424 const patch = azureDevOpsFilePatch({
425 change: change({ path, oldPath: path }),
426 texts: texts("one\n", "two\n"),
427 });
428 const [header, oldLine, newLine] = patch.section.split("\n");
429
430 expect(header).not.toContain("\t");
431 expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`);
432 expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`);
433 });
434});
435
436describe("a diff cursor", () => {
437 it("carries the push it was taken against back to the next slice", () => {
438 const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 });
439
440 expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 });
441 });
442
443 it("reads anything it did not write as no position at all", () => {
444 // Which starts the read from the top rather than failing it: a cursor is the client's to
445 // hand back, and nothing downstream is worth refusing a whole diff over.
446 for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) {
447 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
448 }
449 });
450
451 it("refuses a half it did not write rather than reading it as the first file", () => {
452 // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a
453 // position, and the read would resume against an iteration the client never saw instead of
454 // starting again from the latest one.
455 for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) {
456 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
457 }
458 });
459});
apps/server/src/pullRequest/azureDevOpsDiff.ts created+273
......@@ -0,0 +1,273 @@
1import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { structuredPatch } from "diff";
3
4import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
5
6/**
7 * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed
8 * files off an iteration, so a push landing mid-read would renumber the list under a cursor that
9 * did not also pin the iteration.
10 */
11export interface AzureDevOpsDiffCursor {
12 readonly iterationId: number;
13 readonly fileIndex: number;
14}
15
16const CURSOR_SEPARATOR = ":";
17
18/**
19 * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as
20 * three.
21 */
22const CURSOR_COMPONENT = /^\d+$/;
23
24export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string {
25 return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`;
26}
27
28/** Null for anything this did not write, which starts the read from the top rather than failing. */
29export function parseAzureDevOpsDiffCursor(
30 raw: string | null | undefined,
31): AzureDevOpsDiffCursor | null {
32 if (raw === null || raw === undefined) return null;
33 const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR);
34 if (rest.length > 0) return null;
35 if (iteration === undefined || file === undefined) return null;
36 if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null;
37 const iterationId = Number(iteration);
38 const fileIndex = Number(file);
39 if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null;
40 if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null;
41 return { iterationId, fileIndex };
42}
43
44/** The two texts of one changed file, empty on whichever side the change does not have. */
45export interface AzureDevOpsFileTexts {
46 readonly oldContents: string;
47 readonly newContents: string;
48 /** Azure's own flag for a file it hands back base64-encoded instead of as text. */
49 readonly binary: boolean;
50}
51
52export interface AzureDevOpsFilePatch {
53 readonly section: string;
54 /** The file changed but its hunks are not in the section, so the patch has a hole in it. */
55 readonly truncated: boolean;
56 /**
57 * The diff was given up on partway, having spent the whole edit budget, so a caller reading a
58 * run of files stops here rather than paying that again for each one behind it.
59 */
60 readonly abandoned: boolean;
61 /**
62 * Lines added or removed, which is the edit distance the diff had to search out, and what a
63 * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff.
64 */
65 readonly edits: number;
66}
67
68/**
69 * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather
70 * than a patch, so an oversize file is paid for twice over before anything can be diffed.
71 */
72const MAX_FILE_BYTES = 512 * 1024;
73
74/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */
75const PATCH_CONTEXT_LINES = 3;
76
77/**
78 * How far apart one file's two sides may be before it is listed without its hunks. The line diff
79 * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could
80 * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same
81 * way on every machine.
82 */
83export const MAX_FILE_DIFF_EDITS = 2_000;
84
85/**
86 * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than
87 * this is what decides a patch's shape: a timeout that fires would slice the same change one way
88 * here and another on a busier host.
89 */
90const MAX_FILE_DIFF_MILLIS = 2_000;
91
92/** How much diff work one slice does before the rest is left for the next request. */
93export const MAX_DIFF_SLICE_EDITS = 6_000;
94
95/** How much patch one slice carries before the rest is left for the next request. */
96export const MAX_DIFF_SLICE_BYTES = 256 * 1024;
97
98/**
99 * How many files one slice carries however little each one weighs. A binary, oversize, or
100 * unreadable entry is just a header, so neither budget above stops a run of thousands of them.
101 */
102export const MAX_DIFF_SLICE_FILES = 300;
103
104/** Git's own note for a side whose last line has no newline after it. */
105const NO_NEWLINE_MARKER = "\\ No newline at end of file";
106
107/** A text's lines, without the empty one that a trailing newline leaves behind a split. */
108function contentLines(contents: string): ReadonlyArray<string> {
109 if (contents === "") return [];
110 const lines = contents.split("\n");
111 if (lines.at(-1) === "") lines.pop();
112 return lines;
113}
114
115/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */
116function isBinary(contents: string): boolean {
117 return contents.includes("\u0000");
118}
119
120/**
121 * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted
122 * in characters lets a file of three-byte glyphs through at three times the intended size.
123 */
124export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8");
125
126/**
127 * Git points an empty range at the line before it, which is line zero for a file that is wholly
128 * new or wholly gone, and writes a single line as its number alone.
129 */
130function hunkRange(start: number, lines: number): string {
131 if (lines === 0) return `${start - 1},0`;
132 return lines === 1 ? String(start) : `${start},${lines}`;
133}
134
135/**
136 * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file
137 * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the
138 * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either
139 * would be truncated and its viewed mark would land on the wrong file.
140 */
141function patchHeader(change: AzureDevOpsChangeEntry): string {
142 const oldSide = quoteGitPatchPath(`a/${change.oldPath}`);
143 const newSide = quoteGitPatchPath(`b/${change.path}`);
144 const lines = [`diff --git ${oldSide} ${newSide}`];
145 if (change.changeKind === "new") lines.push("new file mode 100644");
146 if (change.changeKind === "deleted") lines.push("deleted file mode 100644");
147 if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") {
148 lines.push(
149 `rename from ${quoteGitPatchPath(change.oldPath)}`,
150 `rename to ${quoteGitPatchPath(change.path)}`,
151 );
152 }
153 lines.push(
154 `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`,
155 `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`,
156 );
157 return lines.join("\n");
158}
159
160/**
161 * A file written out as wholly replaced: every old line gone, every new line arrived, in one
162 * hunk, which needs no edit-distance search at all.
163 */
164function replacementSection(header: string, texts: AzureDevOpsFileTexts): string {
165 const oldLines = contentLines(texts.oldContents);
166 const newLines = contentLines(texts.newContents);
167 const noNewline = (contents: string, lines: ReadonlyArray<string>) =>
168 lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : [];
169 return [
170 header,
171 `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`,
172 ...oldLines.map((line) => `-${line}`),
173 ...noNewline(texts.oldContents, oldLines),
174 ...newLines.map((line) => `+${line}`),
175 ...noNewline(texts.newContents, newLines),
176 "",
177 ].join("\n");
178}
179
180/**
181 * One file's section of a unified patch, built here because Azure has no route that returns one:
182 * its diff routes name the files that changed, and the contents are a separate read per side.
183 */
184export function azureDevOpsFilePatch(input: {
185 readonly change: AzureDevOpsChangeEntry;
186 readonly texts: AzureDevOpsFileTexts;
187}): AzureDevOpsFilePatch {
188 const header = patchHeader(input.change);
189 const { oldContents, newContents } = input.texts;
190
191 if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) {
192 // Git's own wording for a file it will not spell out.
193 const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`);
194 const newSide = quoteGitPatchPath(`b/${input.change.path}`);
195 const binary = `Binary files ${oldSide} and ${newSide} differ`;
196 return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 };
197 }
198 if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) {
199 return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 };
200 }
201
202 // A creation or deletion has nothing on one side, so writing both sides in full is already the
203 // minimal patch and needs no diff search.
204 const created = oldContents === "" && newContents !== "";
205 const deleted = newContents === "" && oldContents !== "";
206 if (created || deleted) {
207 const contents = created ? newContents : oldContents;
208 const lines = contentLines(contents);
209 // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it.
210 // Checked against bytes-plus-marker-count rather than building the section, since that upper
211 // bound is cheaper and this is exactly the file shape that would cost the most to build.
212 if (byteLength(contents) + lines.length > MAX_FILE_BYTES) {
213 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
214 }
215 const section = replacementSection(header, input.texts);
216 if (byteLength(section) > MAX_FILE_BYTES) {
217 return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length };
218 }
219 return { section, truncated: false, abandoned: false, edits: lines.length };
220 }
221
222 const patch = structuredPatch(
223 `a/${input.change.oldPath}`,
224 `b/${input.change.path}`,
225 oldContents,
226 newContents,
227 undefined,
228 undefined,
229 {
230 context: PATCH_CONTEXT_LINES,
231 maxEditLength: MAX_FILE_DIFF_EDITS,
232 timeout: MAX_FILE_DIFF_MILLIS,
233 },
234 );
235 // Hitting the edit ceiling lists the file without hunks rather than falling back to a full
236 // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having
237 // changed in one small corner, and both sides in full would bury that corner in a wall of text.
238 if (patch === undefined) {
239 return {
240 section: `${header}\n`,
241 truncated: true,
242 abandoned: true,
243 edits: MAX_FILE_DIFF_EDITS,
244 };
245 }
246
247 let edits = 0;
248 const hunks = patch.hunks.map((hunk) => {
249 for (const line of hunk.lines) {
250 if (line.startsWith("+") || line.startsWith("-")) edits += 1;
251 }
252 return [
253 `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`,
254 ...hunk.lines,
255 ].join("\n");
256 });
257 // A pure rename has no hunks to give but is still listed, since dropping it would take the file
258 // out of the change altogether.
259 const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`;
260 // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus
261 // context can still exceed the size ceiling even well inside the edit ceiling.
262 if (byteLength(section) > MAX_FILE_BYTES) {
263 return { section: `${header}\n`, truncated: true, abandoned: false, edits };
264 }
265 return { section, truncated: false, abandoned: false, edits };
266}
267
268/** A file listed without its hunks, for when the host would not hand one of its two sides over. */
269export function azureDevOpsUnreadableFilePatch(
270 change: AzureDevOpsChangeEntry,
271): AzureDevOpsFilePatch {
272 return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 };
273}
apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts+317-8
......@@ -2,6 +2,9 @@ import * as Result from "effect/Result";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 decodeItemContentJson,
6 decodeIterationChangesJson,
7 decodeIterationsJson,
58 decodePullRequestJson,
69 decodePullRequestListJson,
710 decodeThreadsJson,
......@@ -187,17 +190,15 @@ describe("decodePullRequestJson", () => {
187190 expect(unspecified?.autoMergeMethod).toBeUndefined();
188191 });
189192
190 it("works out where the conversation lives from what Azure returned", () => {
193 it("works out where the repository lives from what Azure returned", () => {
191194 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
192195
193 expect(detail?.threadsUrl).toBe(
194 "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads",
195 );
196 expect(detail?.location).toEqual({ project: "platform", repository: "web" });
196197 });
197198
198 it("reports no conversation url when Azure said too little to build one", () => {
199 // A web link places the pull request, but without the REST url and repository there is
200 // nothing to hang a threads collection off.
199 it("reports no repository location when Azure said too little to name one", () => {
200 // A web link places the pull request, but with no repository named there is nothing to
201 // address the routes that read its files and its conversation.
201202 const detail = expectSuccess(
202203 decodePullRequestJson(
203204 asJson(
......@@ -212,7 +213,7 @@ describe("decodePullRequestJson", () => {
212213 ),
213214 );
214215
215 expect(detail?.threadsUrl).toBeNull();
216 expect(detail?.location).toBeNull();
216217 });
217218
218219 it("returns nothing when Azure gave no way to place the pull request at all", () => {
......@@ -341,3 +342,311 @@ describe("decodeThreadsJson", () => {
341342 expect(comments).toEqual([]);
342343 });
343344});
345
346describe("decodeIterationsJson", () => {
347 const iteration = (id: number, head: string, base: string) => ({
348 id,
349 sourceRefCommit: { commitId: head },
350 commonRefCommit: { commitId: base },
351 targetRefCommit: { commitId: base },
352 });
353
354 it("reads every push in order, oldest first", () => {
355 const iterations = expectSuccess(
356 decodeIterationsJson(
357 asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }),
358 ),
359 );
360
361 expect(iterations.map((entry) => entry.id)).toEqual([1, 2]);
362 expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" });
363 });
364
365 it("skips a push Azure could not place both ends of", () => {
366 // A patch is taken over a range, and an iteration missing either end names no range at all.
367 const iterations = expectSuccess(
368 decodeIterationsJson(
369 asJson({
370 value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")],
371 }),
372 ),
373 );
374
375 expect(iterations.map((entry) => entry.id)).toEqual([2]);
376 });
377});
378
379describe("decodeIterationChangesJson", () => {
380 it("names each changed file without the slash Azure leads its paths with", () => {
381 const page = expectSuccess(
382 decodeIterationChangesJson(
383 asJson({
384 changeEntries: [
385 { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } },
386 {
387 changeType: "edit",
388 item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" },
389 },
390 { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } },
391 ],
392 }),
393 ),
394 );
395
396 expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([
397 ["DEMO.md", "new"],
398 ["README.md", "change"],
399 ["OLD.md", "deleted"],
400 ]);
401 });
402
403 it("keeps a space at the end of a file's name, which belongs to the name", () => {
404 // Git will carry a name that ends in a space, and the patch and the viewed mark are both
405 // keyed by it. Tidying it here files the change under a name nothing else uses.
406 const page = expectSuccess(
407 decodeIterationChangesJson(
408 asJson({
409 changeEntries: [
410 { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } },
411 {
412 changeType: "rename",
413 sourceServerItem: "/docs/old.md ",
414 item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" },
415 },
416 ],
417 }),
418 ),
419 );
420
421 expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([
422 ["docs/readme.md ", "docs/readme.md "],
423 ["docs/moved.md", "docs/old.md "],
424 ]);
425 });
426
427 it("reads a rename as one file that moved, and says whether it also changed", () => {
428 const page = expectSuccess(
429 decodeIterationChangesJson(
430 asJson({
431 changeEntries: [
432 {
433 changeType: "rename",
434 sourceServerItem: "/docs/old.md",
435 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
436 },
437 {
438 changeType: "edit, rename",
439 sourceServerItem: "/src/old.ts",
440 item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" },
441 },
442 ],
443 }),
444 ),
445 );
446
447 expect(page.changes).toEqual([
448 {
449 path: "docs/new.md",
450 oldPath: "docs/old.md",
451 changeKind: "rename-pure",
452 objectId: "aaaa",
453 originalObjectId: "aaaa",
454 },
455 {
456 path: "src/new.ts",
457 oldPath: "src/old.ts",
458 changeKind: "rename-changed",
459 objectId: "bbbb",
460 originalObjectId: "cccc",
461 },
462 ]);
463 });
464
465 it("drops the folders Azure lists alongside the files that changed", () => {
466 // A review shows files, and a folder has no content on either side to show for one.
467 const page = expectSuccess(
468 decodeIterationChangesJson(
469 asJson({
470 changeEntries: [
471 { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } },
472 { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } },
473 ],
474 }),
475 ),
476 );
477
478 expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]);
479 });
480
481 it("carries where the next page of a long change starts", () => {
482 const page = expectSuccess(
483 decodeIterationChangesJson(
484 asJson({
485 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
486 nextSkip: 2000,
487 }),
488 ),
489 );
490
491 expect(page.nextSkip).toBe(2000);
492 });
493
494 it("reads the last page, which names no page after it, as the end of the change", () => {
495 const page = expectSuccess(
496 decodeIterationChangesJson(
497 asJson({
498 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
499 }),
500 ),
501 );
502
503 expect(page.nextSkip).toBeNull();
504 });
505
506 it("reads where a rename came from out of either of the two places Azure names it", () => {
507 // The iteration-changes route answers with `originalPath`; the commit routes answer with
508 // `sourceServerItem`, and both are the same fact under two names.
509 const page = expectSuccess(
510 decodeIterationChangesJson(
511 asJson({
512 changeEntries: [
513 {
514 changeType: "rename",
515 originalPath: "/docs/old.md",
516 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
517 },
518 ],
519 }),
520 ),
521 );
522
523 expect(page.changes.at(0)?.oldPath).toBe("docs/old.md");
524 });
525});
526
527describe("decodeItemContentJson", () => {
528 it("reads the file's text out of the envelope Azure wraps it in", () => {
529 expect(
530 expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))),
531 ).toEqual({ contents: "one\ntwo", isBinary: false });
532 });
533
534 it("reads an empty file as empty rather than as a failure to look", () => {
535 expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({
536 contents: "",
537 isBinary: false,
538 });
539 });
540
541 it("keeps Azure's own word that a file is binary", () => {
542 // Which it answers base64-encoded, so nothing in the text it sent would give it away.
543 expect(
544 expectSuccess(
545 decodeItemContentJson(
546 asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }),
547 ),
548 ),
549 ).toEqual({ contents: "b2xk", isBinary: true });
550 });
551});
552
553/**
554 * Every other fixture in this file is written from Azure's published contract. These are the
555 * shapes a real organisation answered with, read back through `az devops invoke` rather than
556 * `az rest`: the extension hands over the route's own body with one key of its own added, and a
557 * live repository leaves out fields the contract documents.
558 */
559describe("what az devops invoke answers with", () => {
560 it("reads the envelope the extension adds its own continuation token to", () => {
561 // Set on every JSON body it returns, from a response header these routes do not send, so it
562 // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode.
563 expect(
564 expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))),
565 ).toEqual([]);
566
567 expect(
568 expectSuccess(
569 decodeIterationsJson(
570 asJson({
571 count: 1,
572 continuation_token: null,
573 value: [
574 {
575 id: 1,
576 sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" },
577 commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" },
578 },
579 ],
580 }),
581 ),
582 ),
583 ).toEqual([
584 {
585 id: 1,
586 headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a",
587 mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35",
588 },
589 ]);
590 });
591
592 it("keeps the files of a change that names neither their object type nor their page after it", () => {
593 // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no
594 // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences
595 // is what the defaults in the decoder are for, and reading any of them as stated would drop
596 // every file of every Azure change request.
597 const page = expectSuccess(
598 decodeIterationChangesJson(
599 asJson({
600 continuation_token: null,
601 changeEntries: [
602 { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } },
603 { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } },
604 ],
605 }),
606 ),
607 );
608
609 expect(page.nextSkip).toBeNull();
610 expect(page.changes).toEqual([
611 {
612 path: "DEMO.md",
613 oldPath: "DEMO.md",
614 changeKind: "new",
615 objectId: "EC005DB24",
616 originalObjectId: null,
617 },
618 {
619 path: "README.md",
620 oldPath: "README.md",
621 changeKind: "change",
622 objectId: "8F8047A49",
623 originalObjectId: null,
624 },
625 ]);
626 });
627
628 it("reads a text file whose content type Azure calls a stream", () => {
629 // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and
630 // no `isBinary` at all, so the content type is not the field to ask, and its absence is the
631 // answer that the text is text.
632 expect(
633 expectSuccess(
634 decodeItemContentJson(
635 asJson({
636 path: "/README.md",
637 objectId: "8f8047a49",
638 gitObjectType: "blob",
639 content: "# T3Demo\n",
640 contentMetadata: {
641 contentType: "application/octet-stream",
642 encoding: 65001,
643 extension: "md",
644 fileName: "README.md",
645 },
646 continuation_token: null,
647 }),
648 ),
649 ),
650 ).toEqual({ contents: "# T3Demo\n", isBinary: false });
651 });
652});
apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts+215-13
......@@ -12,10 +12,7 @@ import type {
1212import { TrimmedNonEmptyString } from "@t3tools/contracts";
1313import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1414
15import {
16 azureDevOpsOrganizationBaseFromRestApiUrl,
17 azureDevOpsPullRequestWebUrl,
18} from "../sourceControl/azureDevOpsPullRequests.ts";
15import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts";
1916
2017/**
2118 * 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({
117114 ),
118115});
119116
117/**
118 * Where a repository lives, in the terms Azure's REST routes address it by. They take the project
119 * and the repository as separate route parameters rather than as one path, so the pair travels
120 * together rather than as a URL that would have to be taken apart again to use.
121 */
122export interface AzureDevOpsRepositoryLocation {
123 readonly project: string;
124 readonly repository: string;
125}
126
120127export interface AzureDevOpsPullRequest {
121128 readonly number: number;
122129 readonly title: string;
......@@ -137,8 +144,8 @@ export interface AzureDevOpsPullRequest {
137144 readonly body: string;
138145 readonly reviewRequestLogins: ReadonlyArray<string>;
139146 readonly reviewers: ReadonlyArray<PullRequestActor>;
140 /** Where this pull request's threads live, when Azure said enough to work it out. */
141 readonly threadsUrl: string | null;
147 /** Where this pull request lives, when Azure said enough to work it out. */
148 readonly location: AzureDevOpsRepositoryLocation | null;
142149 /** Whether Azure is set to complete this on its own once its policies pass. */
143150 readonly autoMergeEnabled: boolean;
144151 /** The completion strategy Azure stored with auto-complete, where it reported one. */
......@@ -188,15 +195,16 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili
188195}
189196
190197/**
191 * The REST collection a pull request's threads hang from. Built from what Azure returned rather
192 * than from the local remote, whose shape differs between the modern, legacy and SSH forms.
198 * Where a pull request's own repository sits. Taken from what Azure returned rather than from the
199 * local remote, whose shape differs between the modern, legacy and SSH forms.
193200 */
194function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): string | null {
195 const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url);
201function toLocation(
202 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
203): AzureDevOpsRepositoryLocation | null {
196204 const project = trimmed(raw.repository?.project?.name);
197205 const repository = trimmed(raw.repository?.name);
198 if (base === null || project === null || repository === null) return null;
199 return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`;
206 if (project === null || repository === null) return null;
207 return { project, repository };
200208}
201209
202210function toAutoMergeMethod(
......@@ -259,7 +267,7 @@ function toPullRequest(
259267 body: raw.description ?? "",
260268 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
261269 reviewers,
262 threadsUrl: toThreadsUrl(raw),
270 location: toLocation(raw),
263271 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
264272 ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
265273 };
......@@ -370,3 +378,197 @@ export function decodeThreadsJson(
370378 comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
371379 );
372380}
381
382/**
383 * One push's worth of a pull request. Azure records every push as an iteration and keys the whole
384 * review off them: the changed files, and the marks a reader leaves on those files, both hang
385 * from an iteration rather than from the pull request.
386 */
387const RawIterationSchema = Schema.Struct({
388 id: Schema.Int,
389 sourceRefCommit: Schema.optional(
390 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
391 ),
392 commonRefCommit: Schema.optional(
393 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
394 ),
395});
396
397const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) });
398
399const RawChangeEntrySchema = Schema.Struct({
400 changeType: Schema.optional(Schema.NullOr(Schema.String)),
401 sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)),
402 /** Where a renamed file came from. Azure states it here on an iteration's changes. */
403 originalPath: Schema.optional(Schema.NullOr(Schema.String)),
404 item: Schema.optional(
405 Schema.NullOr(
406 Schema.Struct({
407 path: Schema.optional(Schema.NullOr(Schema.String)),
408 objectId: Schema.optional(Schema.NullOr(Schema.String)),
409 originalObjectId: Schema.optional(Schema.NullOr(Schema.String)),
410 /** Azure marks a directory this way; a review has nothing to show for one. */
411 isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)),
412 gitObjectType: Schema.optional(Schema.NullOr(Schema.String)),
413 }),
414 ),
415 ),
416});
417
418const RawChangePageSchema = Schema.Struct({
419 changeEntries: Schema.Array(Schema.Unknown),
420 /** Where the page after this one starts. Azure leaves it out on the last page. */
421 nextSkip: Schema.optional(Schema.NullOr(Schema.Number)),
422});
423
424const RawItemContentSchema = Schema.Struct({
425 content: Schema.optional(Schema.NullOr(Schema.String)),
426 /** What Azure makes of the file it is handing over, which is where it says it is not text. */
427 contentMetadata: Schema.optional(
428 Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })),
429 ),
430});
431
432/** The head and the merge base of one iteration, which is the range its patch is taken over. */
433export interface AzureDevOpsIteration {
434 readonly id: number;
435 readonly headCommit: string;
436 readonly mergeBaseCommit: string;
437}
438
439/**
440 * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which
441 * Azure reports by naming the file's previous home rather than as a delete and an add.
442 */
443export interface AzureDevOpsChangeEntry {
444 readonly path: string;
445 readonly oldPath: string;
446 readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed";
447 readonly objectId: string | null;
448 readonly originalObjectId: string | null;
449}
450
451/**
452 * One page of what an iteration changed, and where the next one starts. Azure pages this route
453 * rather than answering with the whole change, so a review large enough to be paged is followed
454 * to its end instead of being cut off at the first page's worth.
455 */
456export interface AzureDevOpsChangePage {
457 readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>;
458 readonly nextSkip: number | null;
459}
460
461/** One file's text at one commit, and whether Azure says the text is text at all. */
462export interface AzureDevOpsItemContent {
463 readonly contents: string;
464 readonly isBinary: boolean;
465}
466
467const decodeIterationPage = decodeJsonResult(RawIterationPageSchema);
468const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema);
469const decodeChangePage = decodeJsonResult(RawChangePageSchema);
470const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema);
471const decodeItemContent = decodeJsonResult(RawItemContentSchema);
472
473/**
474 * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like
475 * the rest of this payload, since a leading/trailing space is a legal part of a file's name and
476 * the patch and viewed mark are keyed by the untrimmed path.
477 */
478function toRepositoryPath(value: string | null | undefined): string | null {
479 if (value === undefined || value === null) return null;
480 const path = value.replace(/^\/+/, "");
481 return path.length === 0 ? null : path;
482}
483
484/**
485 * Azure names a change with one word or two, and a rename arrives either alone or alongside the
486 * edit that came with it. Anything it has added since reads as a plain change, which shows the
487 * file rather than dropping it from the review.
488 */
489function toChangeKind(
490 raw: string | null | undefined,
491 renamed: boolean,
492): AzureDevOpsChangeEntry["changeKind"] {
493 const parts = new Set(
494 (raw ?? "")
495 .toLowerCase()
496 .split(",")
497 .map((part) => part.trim())
498 .filter((part) => part.length > 0),
499 );
500 if (parts.has("delete")) return "deleted";
501 if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure";
502 if (parts.has("add")) return "new";
503 return "change";
504}
505
506export function decodeIterationsJson(
507 raw: string,
508): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> {
509 const decoded = decodeIterationPage(raw);
510 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
511 const iterations: AzureDevOpsIteration[] = [];
512 for (const entry of decoded.success.value) {
513 const decodedIteration = decodeIterationEntry(entry);
514 if (Exit.isFailure(decodedIteration)) continue;
515 const iteration = decodedIteration.value;
516 const headCommit = trimmed(iteration.sourceRefCommit?.commitId);
517 const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId);
518 // An iteration Azure cannot place both ends of names no range, and a patch needs both.
519 if (headCommit === null || mergeBaseCommit === null) continue;
520 iterations.push({ id: iteration.id, headCommit, mergeBaseCommit });
521 }
522 return Result.succeed(iterations.toSorted((left, right) => left.id - right.id));
523}
524
525export function decodeIterationChangesJson(
526 raw: string,
527): Result.Result<AzureDevOpsChangePage, DecodeFailure> {
528 const decoded = decodeChangePage(raw);
529 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
530 const changes: AzureDevOpsChangeEntry[] = [];
531 for (const entry of decoded.success.changeEntries) {
532 const decodedChange = decodeChangeEntry(entry);
533 if (Exit.isFailure(decodedChange)) continue;
534 const change = decodedChange.value;
535 const path = toRepositoryPath(change.item?.path);
536 if (path === null) continue;
537 // Azure lists the folders a change touched alongside the files themselves. A review shows
538 // files, and a folder has no content to show for either side of one.
539 if (change.item?.isFolder === true) continue;
540 if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue;
541 // Azure names where a renamed file came from in either of two places depending on the route
542 // and the version, so both are read and the current path stands in when neither is there.
543 const oldPath =
544 toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path;
545 changes.push({
546 path,
547 oldPath,
548 changeKind: toChangeKind(change.changeType, oldPath !== path),
549 objectId: trimmed(change.item?.objectId),
550 originalObjectId: trimmed(change.item?.originalObjectId),
551 });
552 }
553 const nextSkip = decoded.success.nextSkip ?? null;
554 return Result.succeed({
555 changes,
556 nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null,
557 });
558}
559
560/**
561 * Azure answers an absent file with an empty body rather than an error, which reads as empty.
562 * Whether the bytes are text is Azure's own call, not this decoder's to guess from content.
563 */
564export function decodeItemContentJson(
565 raw: string,
566): Result.Result<AzureDevOpsItemContent, DecodeFailure> {
567 const decoded = decodeItemContent(raw);
568 return Result.isSuccess(decoded)
569 ? Result.succeed({
570 contents: decoded.success.content ?? "",
571 isBinary: decoded.success.contentMetadata?.isBinary === true,
572 })
573 : Result.fail(decoded.failure);
574}
apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts created+280
......@@ -0,0 +1,280 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
4
5function patchOf(...lines: ReadonlyArray<string>): string {
6 return `${lines.join("\n")}\n`;
7}
8
9describe("parseDiffFileRevisions", () => {
10 it("reads the head id of a changed file off its index line", () => {
11 const revisions = parseDiffFileRevisions(
12 patchOf(
13 "diff --git a/src/a.ts b/src/a.ts",
14 "index 7f2aa0ab6..b4a2a7c9a 100644",
15 "--- a/src/a.ts",
16 "+++ b/src/a.ts",
17 "@@ -1 +1 @@",
18 "-a",
19 "+b",
20 ),
21 );
22
23 assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]);
24 });
25
26 it("names a deletion by the path it had, which is the one still on screen", () => {
27 const revisions = parseDiffFileRevisions(
28 patchOf(
29 "diff --git a/gone.ts b/gone.ts",
30 "deleted file mode 100644",
31 "index 1111111..0000000",
32 "--- a/gone.ts",
33 "+++ /dev/null",
34 "@@ -1 +0,0 @@",
35 "-x",
36 ),
37 );
38
39 assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]);
40 });
41
42 it("names a rename by where it moved to", () => {
43 const revisions = parseDiffFileRevisions(
44 patchOf(
45 "diff --git a/old.ts b/new.ts",
46 "similarity index 90%",
47 "rename from old.ts",
48 "rename to new.ts",
49 "index 2222222..3333333 100644",
50 "--- a/old.ts",
51 "+++ b/new.ts",
52 "@@ -1 +1 @@",
53 "-a",
54 "+b",
55 ),
56 );
57
58 assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]);
59 });
60
61 it("names a rename that changed nothing, which states no paths of its own", () => {
62 const revisions = parseDiffFileRevisions(
63 patchOf(
64 "diff --git a/old.ts b/new.ts",
65 "similarity index 100%",
66 "rename from old.ts",
67 "rename to new.ts",
68 "index 2222222..2222222 100644",
69 ),
70 );
71
72 assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]);
73 });
74
75 it("leaves out a file Bitbucket excluded, which it gives no index line for", () => {
76 const revisions = parseDiffFileRevisions(
77 patchOf(
78 "diff --git a/package.json b/package.json",
79 "index 7f2aa0ab6..b4a2a7c9a 100644",
80 "--- a/package.json",
81 "+++ b/package.json",
82 "@@ -1 +1 @@",
83 '- "x": "1"',
84 '+ "x": "2"',
85 "diff --git a/yarn.lock b/yarn.lock",
86 'File excluded by pattern "yarn.lock"',
87 ),
88 );
89
90 assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]);
91 });
92
93 it("stops reading headers at the first hunk, so content cannot pose as one", () => {
94 const revisions = parseDiffFileRevisions(
95 patchOf(
96 "diff --git a/notes.md b/notes.md",
97 "index aaaaaaa..bbbbbbb 100644",
98 "--- a/notes.md",
99 "+++ b/notes.md",
100 "@@ -1,2 +1,2 @@",
101 "--- a/decoy.ts",
102 "+++ b/decoy.ts",
103 "+index ccccccc..ddddddd 100644",
104 ),
105 );
106
107 assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]);
108 });
109
110 it("splits a header whose paths contain the separator, by the sides agreeing", () => {
111 const revisions = parseDiffFileRevisions(
112 patchOf(
113 "diff --git a/one b/two.ts b/one b/two.ts",
114 "index eeeeeee..fffffff 100644",
115 "@@ -1 +1 @@",
116 ),
117 );
118
119 assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]);
120 });
121
122 it("leaves out an added file that Bitbucket sent no index line for", () => {
123 const revisions = parseDiffFileRevisions(
124 patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"),
125 );
126
127 assert.strictEqual(revisions.size, 0);
128 });
129
130 it("reads nothing out of an empty patch", () => {
131 assert.strictEqual(parseDiffFileRevisions("").size, 0);
132 });
133
134 it("reads a name git had to quote, here one holding a tab", () => {
135 const revisions = parseDiffFileRevisions(
136 patchOf(
137 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"',
138 "index 4444444..5555555 100644",
139 '--- "a/we\\tird.ts"',
140 '+++ "b/we\\tird.ts"',
141 "@@ -1 +1 @@",
142 "-a",
143 "+b",
144 ),
145 );
146
147 assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]);
148 });
149
150 it("rejoins the octal bytes git writes for a name outside ASCII", () => {
151 const revisions = parseDiffFileRevisions(
152 patchOf(
153 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"',
154 "index 6666666..7777777 100644",
155 "@@ -1 +1 @@",
156 ),
157 );
158
159 assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]);
160 });
161
162 it("splits a rename header where git quoted only the side that needed it", () => {
163 const revisions = parseDiffFileRevisions(
164 patchOf(
165 'diff --git a/old.ts "b/new\\tname.ts"',
166 "similarity index 90%",
167 "rename from old.ts",
168 'rename to "new\\tname.ts"',
169 "index 8888888..9999999 100644",
170 "--- a/old.ts",
171 '+++ "b/new\\tname.ts"',
172 "@@ -1 +1 @@",
173 "-a",
174 "+b",
175 ),
176 );
177
178 assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]);
179 });
180
181 it("names a quoted rename that changed nothing, which states no paths of its own", () => {
182 const revisions = parseDiffFileRevisions(
183 patchOf(
184 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
185 "similarity index 100%",
186 'rename from "old\\tname.ts"',
187 'rename to "new\\tname.ts"',
188 "index abcabca..abcabca 100644",
189 ),
190 );
191
192 assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]);
193 });
194
195 it("keeps a character from outside the basic plane that git left unescaped", () => {
196 // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header
197 // for the tab. Encoding what it left one unit at a time would split the pair into two halves.
198 const revisions = parseDiffFileRevisions(
199 patchOf(
200 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"',
201 "index aaaaaaa..bbbbbbb 100644",
202 "@@ -1 +1 @@",
203 ),
204 );
205
206 assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]);
207 });
208
209 it("drops the tab git ends a name holding a space with", () => {
210 const revisions = parseDiffFileRevisions(
211 patchOf(
212 "diff --git a/with space.txt b/with space.txt",
213 "index 1111111..6178079 100644",
214 "--- a/with space.txt\t",
215 "+++ b/with space.txt\t",
216 "@@ -1 +1 @@",
217 "-a",
218 "+b",
219 ),
220 );
221
222 assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]);
223 });
224
225 it("drops that tab from a quoted name too, where it lands past the closing quote", () => {
226 const revisions = parseDiffFileRevisions(
227 patchOf(
228 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"',
229 "index 2222222..7777777 100644",
230 '--- "a/we ird\\tname.ts"\t',
231 '+++ "b/we ird\\tname.ts"\t',
232 "@@ -1 +1 @@",
233 "-a",
234 "+b",
235 ),
236 );
237
238 assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]);
239 });
240
241 it("reads a deletion whose name holds a space, tab and all", () => {
242 const revisions = parseDiffFileRevisions(
243 patchOf(
244 "diff --git a/with space.txt b/with space.txt",
245 "deleted file mode 100644",
246 "index 3333333..0000000",
247 "--- a/with space.txt\t",
248 "+++ /dev/null",
249 "@@ -1 +0,0 @@",
250 "-a",
251 ),
252 );
253
254 assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]);
255 });
256
257 it("drops the timestamp other producers of the format write past that tab", () => {
258 const revisions = parseDiffFileRevisions(
259 patchOf(
260 "diff --git a/stamped.ts b/stamped.ts",
261 "index 4444444..5555555 100644",
262 "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000",
263 "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000",
264 "@@ -1 +1 @@",
265 "-a",
266 "+b",
267 ),
268 );
269
270 assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]);
271 });
272
273 it("splits an unquoted header whose names hold a space, by the sides agreeing", () => {
274 const revisions = parseDiffFileRevisions(
275 patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"),
276 );
277
278 assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]);
279 });
280});
apps/server/src/pullRequest/bitbucketDiffRevisions.ts created+129
......@@ -0,0 +1,129 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2
3const ENTRY = "diff --git ";
4const QUOTE = '"';
5
6interface Entry {
7 oldPath: string | null;
8 newPath: string | null;
9 deleted: boolean;
10 revision: string | null;
11 /** Past the first hunk header every line is content, and content can start like a header. */
12 inBody: boolean;
13}
14
15/** Where a quoted name closes, given git escapes every quote the name itself holds. */
16function quotedEnd(rest: string): number {
17 for (let at = 1; at < rest.length; at += 1) {
18 const char = rest.charAt(at);
19 if (char === "\\") {
20 at += 1;
21 continue;
22 }
23 if (char === QUOTE) return at;
24 }
25 return -1;
26}
27
28/**
29 * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
30 * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
31 * that tab escaped, so the first literal tab is never part of what the file is called.
32 */
33function sidePath(rest: string, prefix: string): string | null {
34 const tab = rest.indexOf("\t");
35 const token = tab === -1 ? rest : rest.slice(0, tab);
36 if (token === "/dev/null") return null;
37 const path = unquoteGitPatchPath(token);
38 return path.startsWith(prefix) ? path.slice(prefix.length) : path;
39}
40
41function headerSide(token: string, prefix: string): string | null {
42 const path = unquoteGitPatchPath(token);
43 return path.startsWith(prefix) ? path.slice(prefix.length) : null;
44}
45
46/**
47 * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
48 * two` can split in more than one place, so the split leaving both sides equal wins; a rename
49 * (the only case where sides differ) states its names on separate lines instead. A quoted name
50 * ends at its own closing quote and needs none of that guessing.
51 */
52function headerPaths(rest: string): readonly [string | null, string | null] {
53 if (rest.startsWith(QUOTE)) {
54 const end = quotedEnd(rest);
55 if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
56 return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
57 }
58 if (rest.endsWith(QUOTE)) {
59 const opens = rest.indexOf(QUOTE);
60 if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
61 return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
62 }
63 if (!rest.startsWith("a/")) return [null, null];
64 const splits: Array<number> = [];
65 for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
66 const chosen =
67 splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
68 (splits.length === 1 ? splits[0] : undefined);
69 return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
70}
71
72/** The right-hand id of `index <before>..<after> <mode>`. */
73function headRevision(rest: string): string | null {
74 const gap = rest.indexOf("..");
75 if (gap === -1) return null;
76 const after = rest.slice(gap + 2);
77 const end = after.indexOf(" ");
78 const head = end === -1 ? after : after.slice(0, end);
79 return head.length === 0 ? null : head;
80}
81
82/**
83 * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>`
84 * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
85 * for a deletion where only the old name exists. An entry with no `index` line (most often one
86 * Bitbucket excluded by pattern) is left out.
87 */
88export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> {
89 const revisions = new Map<string, string>();
90 let entry: Entry | null = null;
91
92 const close = () => {
93 if (entry === null) return;
94 const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
95 if (path !== null && path.length > 0 && entry.revision !== null) {
96 revisions.set(path, entry.revision);
97 }
98 entry = null;
99 };
100
101 for (const line of patch.split("\n")) {
102 if (line.startsWith(ENTRY)) {
103 close();
104 const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
105 entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
106 continue;
107 }
108 if (entry === null || entry.inBody) continue;
109 if (line.startsWith("@@")) {
110 entry.inBody = true;
111 } else if (line.startsWith("index ")) {
112 entry.revision = headRevision(line.slice("index ".length));
113 } else if (line.startsWith("deleted file mode")) {
114 entry.deleted = true;
115 } else if (line.startsWith("rename from ")) {
116 entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
117 } else if (line.startsWith("rename to ")) {
118 entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
119 } else if (line.startsWith("--- ")) {
120 entry.oldPath = sidePath(line.slice(4), "a/");
121 } else if (line.startsWith("+++ ")) {
122 const side = sidePath(line.slice(4), "b/");
123 entry.newPath = side;
124 if (side === null) entry.deleted = true;
125 }
126 }
127 close();
128 return revisions;
129}
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+147
......@@ -6,10 +6,12 @@ import {
66 buildPullRequestStackMembershipsGraphQlQuery,
77 decodePullRequestStackMembershipsJson,
88 buildReviewerRequestJson,
9 buildSetFilesViewedGraphQlMutation,
910 decodeBaseComparisonJson,
1011 decodePullRequestActivityJson,
1112 decodePullRequestDetailJson,
1213 decodePullRequestFilesJson,
14 decodePullRequestFilesViewedJson,
1315 decodePullRequestListJson,
1416 decodePullRequestNodeIdJson,
1517 decodePullRequestSearchJson,
......@@ -1360,6 +1362,57 @@ describe("review submission payload", () => {
13601362});
13611363
13621364describe("decodePullRequestFilesJson", () => {
1365 it("quotes literal backslashes without interpreting them as escapes", () => {
1366 const result = expectSuccess(
1367 decodePullRequestFilesJson(
1368 JSON.stringify([
1369 {
1370 filename: String.raw`src\notes.ts`,
1371 status: "modified",
1372 patch: "@@ -1 +1 @@\n-old\n+new",
1373 },
1374 ]),
1375 ),
1376 );
1377
1378 expect(result.patch).toBe(
1379 [
1380 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
1381 String.raw`--- "a/src\\notes.ts"`,
1382 String.raw`+++ "b/src\\notes.ts"`,
1383 "@@ -1 +1 @@",
1384 "-old",
1385 "+new",
1386 "",
1387 ].join("\n"),
1388 );
1389 });
1390
1391 it("preserves spaces and literal backslashes in both rename paths", () => {
1392 const result = expectSuccess(
1393 decodePullRequestFilesJson(
1394 JSON.stringify([
1395 {
1396 previous_filename: String.raw` old\name.ts `,
1397 filename: String.raw` new\name.ts `,
1398 status: "renamed",
1399 },
1400 ]),
1401 ),
1402 );
1403
1404 expect(result.patch).toBe(
1405 [
1406 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
1407 String.raw`rename from " old\\name.ts "`,
1408 String.raw`rename to " new\\name.ts "`,
1409 String.raw`--- "a/ old\\name.ts "`,
1410 String.raw`+++ "b/ new\\name.ts "`,
1411 "",
1412 ].join("\n"),
1413 );
1414 });
1415
13631416 it("assembles a unified patch the files API does not return", () => {
13641417 const result = expectSuccess(
13651418 decodePullRequestFilesJson(
......@@ -1528,6 +1581,100 @@ describe("how far a branch trails its base", () => {
15281581 });
15291582});
15301583
1584describe("decodePullRequestFilesViewedJson", () => {
1585 const page = (
1586 nodes: ReadonlyArray<unknown>,
1587 pageInfo: { hasNextPage: boolean; endCursor: string | null },
1588 ) =>
1589 JSON.stringify({
1590 data: { repository: { pullRequest: { files: { pageInfo, nodes } } } },
1591 });
1592
1593 it("reads each file's state and where the next page carries on", () => {
1594 const decoded = decodePullRequestFilesViewedJson(
1595 page(
1596 [
1597 { path: "src/a.ts", viewerViewedState: "VIEWED" },
1598 { path: "src/b.ts", viewerViewedState: "UNVIEWED" },
1599 { path: "src/c.ts", viewerViewedState: "DISMISSED" },
1600 ],
1601 { hasNextPage: true, endCursor: "cursor-2" },
1602 ),
1603 );
1604 expect(Result.isSuccess(decoded)).toBe(true);
1605 if (!Result.isSuccess(decoded)) return;
1606 expect(decoded.success).toEqual({
1607 files: [
1608 { path: "src/a.ts", state: "viewed" },
1609 { path: "src/b.ts", state: "unviewed" },
1610 { path: "src/c.ts", state: "dismissed" },
1611 ],
1612 nextCursor: "cursor-2",
1613 });
1614 });
1615
1616 it("treats a state it has never heard of as unread rather than failing the page", () => {
1617 const decoded = decodePullRequestFilesViewedJson(
1618 page([{ path: "src/a.ts", viewerViewedState: "SOMETHING_NEW" }], {
1619 hasNextPage: false,
1620 endCursor: null,
1621 }),
1622 );
1623 expect(Result.isSuccess(decoded)).toBe(true);
1624 if (!Result.isSuccess(decoded)) return;
1625 expect(decoded.success).toEqual({
1626 files: [{ path: "src/a.ts", state: "unviewed" }],
1627 nextCursor: null,
1628 });
1629 });
1630
1631 it("answers empty for a pull request the host has nothing to say about", () => {
1632 const decoded = decodePullRequestFilesViewedJson(
1633 JSON.stringify({ data: { repository: { pullRequest: null } } }),
1634 );
1635 expect(Result.isSuccess(decoded)).toBe(true);
1636 if (!Result.isSuccess(decoded)) return;
1637 expect(decoded.success).toEqual({ files: [], nextCursor: null });
1638 });
1639});
1640
1641describe("buildSetFilesViewedGraphQlMutation", () => {
1642 it("asks for nothing when nothing was pressed", () => {
1643 expect(buildSetFilesViewedGraphQlMutation([])).toBeNull();
1644 });
1645
1646 it("clears and restores in one document, each file under its own alias", () => {
1647 const mutation = buildSetFilesViewedGraphQlMutation([
1648 { path: "src/a.ts", viewed: true },
1649 { path: "src/b.ts", viewed: false },
1650 ]);
1651 expect(mutation).not.toBeNull();
1652 if (mutation === null) return;
1653 expect(mutation.query).toContain(
1654 "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)",
1655 );
1656 expect(mutation.query).toContain(
1657 "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })",
1658 );
1659 expect(mutation.query).toContain(
1660 "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })",
1661 );
1662 expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" });
1663 });
1664
1665 it("keeps a path out of the document, so one cannot be read as part of it", () => {
1666 const mutation = buildSetFilesViewedGraphQlMutation([
1667 { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true },
1668 ]);
1669 expect(mutation).not.toBeNull();
1670 if (mutation === null) return;
1671 expect(mutation.query).not.toContain("evil");
1672 expect(mutation.variables.path0).toBe(
1673 '") { __typename } evil: markFileAsViewed(input: { path: "x',
1674 );
1675 });
1676});
1677
15311678describe("host-native stack decoding", () => {
15321679 /** A stack as the preview lists it, bottom to top, with the fields it answers today. */
15331680 function stack(overrides: Record<string, unknown> = {}) {
apps/server/src/pullRequest/gitHubPullRequestJson.ts+125-5
......@@ -10,6 +10,7 @@ import type {
1010 PullRequestChecksState,
1111 PullRequestComment,
1212 PullRequestCommit,
13 PullRequestFileViewedState,
1314 PullRequestLabel,
1415 PullRequestMergeCapabilities,
1516 PullRequestMergeMethod,
......@@ -30,6 +31,7 @@ import type {
3031 PullRequestState,
3132 PullRequestThreadComment,
3233} from "@t3tools/contracts";
34import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
3335import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3436
3537import { dedupeChecks } from "./pullRequestChecks.ts";
......@@ -2510,16 +2512,21 @@ export function decodePullRequestFilesJson(
25102512 }
25112513 // A rename counts its hunks against the old path, which is the only place it is named.
25122514 const oldPath =
2513 status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename;
2515 status === "renamed" ? value.previous_filename || value.filename : value.filename;
25142516 const header = [
2515 `diff --git a/${oldPath} b/${value.filename}`,
2517 `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`,
25162518 // The files API reports no file mode, so the ordinary one stands in: the viewer reads
25172519 // these lines as "added" and "removed" rather than for the mode they carry.
25182520 ...(status === "added" ? ["new file mode 100644"] : []),
25192521 ...(status === "removed" ? ["deleted file mode 100644"] : []),
2520 ...(status === "renamed" ? [`rename from ${oldPath}`, `rename to ${value.filename}`] : []),
2521 `--- ${status === "added" ? "/dev/null" : `a/${oldPath}`}`,
2522 `+++ ${status === "removed" ? "/dev/null" : `b/${value.filename}`}`,
2522 ...(status === "renamed"
2523 ? [
2524 `rename from ${quoteGitPatchPath(oldPath)}`,
2525 `rename to ${quoteGitPatchPath(value.filename)}`,
2526 ]
2527 : []),
2528 `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`,
2529 `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`,
25232530 ].join("\n");
25242531 sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
25252532 }
......@@ -2531,6 +2538,119 @@ export function decodePullRequestFilesJson(
25312538 });
25322539}
25332540
2541/**
2542 * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST
2543 * files endpoint the patch is read from carries no viewed state, so this is a second read rather
2544 * than a wider version of the first.
2545 */
2546export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
2547 repository(owner: $owner, name: $name) {
2548 pullRequest(number: $number) {
2549 files(first: 100, after: $after) {
2550 pageInfo { hasNextPage endCursor }
2551 nodes { path viewerViewedState }
2552 }
2553 }
2554 }
2555}`;
2556
2557const RawPullRequestFilesViewedSchema = Schema.Struct({
2558 data: Schema.Struct({
2559 repository: Schema.NullOr(
2560 Schema.Struct({
2561 pullRequest: Schema.NullOr(
2562 Schema.Struct({
2563 files: Schema.Struct({
2564 pageInfo: Schema.Struct({
2565 hasNextPage: Schema.Boolean,
2566 endCursor: Schema.NullOr(Schema.String),
2567 }),
2568 nodes: Schema.NullOr(
2569 Schema.Array(
2570 Schema.NullOr(
2571 Schema.Struct({
2572 path: Schema.String,
2573 // Decoded as a plain string and narrowed below: a GitHub release that adds
2574 // a fourth state must not fail the whole page.
2575 viewerViewedState: Schema.String,
2576 }),
2577 ),
2578 ),
2579 ),
2580 }),
2581 }),
2582 ),
2583 }),
2584 ),
2585 }),
2586});
2587
2588const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema);
2589
2590export interface GitHubPullRequestFilesViewedPage {
2591 readonly files: ReadonlyArray<{
2592 readonly path: string;
2593 readonly state: PullRequestFileViewedState;
2594 }>;
2595 /** Where the next page carries on, or null once the host has no more to give. */
2596 readonly nextCursor: string | null;
2597}
2598
2599/** Anything this host does not name is treated as unread, which is the state that asks for least. */
2600function toFileViewedState(raw: string): PullRequestFileViewedState {
2601 switch (raw.trim().toUpperCase()) {
2602 case "VIEWED":
2603 return "viewed";
2604 case "DISMISSED":
2605 return "dismissed";
2606 default:
2607 return "unviewed";
2608 }
2609}
2610
2611export function decodePullRequestFilesViewedJson(
2612 raw: string,
2613): Result.Result<GitHubPullRequestFilesViewedPage, DecodeFailure> {
2614 const decoded = decodePullRequestFilesViewed(raw);
2615 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2616 const files = decoded.success.data.repository?.pullRequest?.files;
2617 if (files === undefined) return Result.succeed({ files: [], nextCursor: null });
2618 return Result.succeed({
2619 files: (files.nodes ?? []).flatMap((node) =>
2620 node === null || node.path.length === 0
2621 ? []
2622 : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }],
2623 ),
2624 nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null,
2625 });
2626}
2627
2628/**
2629 * One document that clears and restores as many files as the reader ticked, rather than one
2630 * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each
2631 * take a single path, so the batching is done with aliases; top-level mutation fields run in
2632 * write order, so the last word about a path is the one that sticks.
2633 *
2634 * Paths travel as variables rather than interpolated into the document, since a path is data
2635 * and a document is not.
2636 */
2637export function buildSetFilesViewedGraphQlMutation(
2638 files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>,
2639): { readonly query: string; readonly variables: Readonly<Record<string, string>> } | null {
2640 if (files.length === 0) return null;
2641 const parameters = files.map((_, index) => `$path${index}: String!`).join(", ");
2642 const fields = files
2643 .map(
2644 (file, index) =>
2645 ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`,
2646 )
2647 .join("\n");
2648 return {
2649 query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`,
2650 variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])),
2651 };
2652}
2653
25342654/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
25352655const RawStackPullRequestSchema = Schema.Struct({
25362656 title: Schema.optional(Schema.String),
apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts+171
......@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33
44import {
55 decodeAwardEmojiJson,
6 decodeRepositoryBlobsJson,
67 decodeCommitsJson,
78 decodeMergeRequestDetailJson,
89 decodeMergeRequestDiffsJson,
......@@ -381,6 +382,57 @@ describe("decodeCommitsJson", () => {
381382});
382383
383384describe("decodeMergeRequestDiffsJson", () => {
385 it("quotes literal backslashes without interpreting them as escapes", () => {
386 const result = expectSuccess(
387 decodeMergeRequestDiffsJson(
388 JSON.stringify([
389 {
390 old_path: String.raw`src\notes.ts`,
391 new_path: String.raw`src\notes.ts`,
392 diff: "@@ -1 +1 @@\n-old\n+new",
393 },
394 ]),
395 ),
396 );
397
398 expect(result.patch).toBe(
399 [
400 String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`,
401 String.raw`--- "a/src\\notes.ts"`,
402 String.raw`+++ "b/src\\notes.ts"`,
403 "@@ -1 +1 @@",
404 "-old",
405 "+new",
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("preserves spaces and literal backslashes in both rename paths", () => {
412 const result = expectSuccess(
413 decodeMergeRequestDiffsJson(
414 JSON.stringify([
415 {
416 old_path: String.raw` old\name.ts `,
417 new_path: String.raw` new\name.ts `,
418 renamed_file: true,
419 diff: "",
420 },
421 ]),
422 ),
423 );
424
425 expect(result.patch).toBe(
426 [
427 String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`,
428 String.raw`rename from " old\\name.ts "`,
429 String.raw`rename to " new\\name.ts "`,
430 String.raw`--- "a/ old\\name.ts "`,
431 String.raw`+++ "b/ new\\name.ts "`,
432 ].join("\n"),
433 );
434 });
435
384436 it("assembles a unified patch GitLab does not return", () => {
385437 const result = expectSuccess(
386438 decodeMergeRequestDiffsJson(
......@@ -630,3 +682,122 @@ describe("gitLabAwardName", () => {
630682 expect(gitLabAwardName("hooray")).toBe("tada");
631683 });
632684});
685
686describe("decodeRepositoryBlobsJson", () => {
687 /** Null is the query going unanswered, which these cases are not about. */
688 function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) {
689 const blobs = expectSuccess(result);
690 expect(blobs).not.toBe(null);
691 if (blobs === null) throw new Error("expected an answered blobs query");
692 return blobs;
693 }
694
695 it("reads a blob id per path", () => {
696 const blobs = expectBlobs(
697 decodeRepositoryBlobsJson(
698 JSON.stringify({
699 data: {
700 project: {
701 repository: {
702 blobs: {
703 nodes: [
704 { path: "src/a.ts", oid: "aaa111" },
705 { path: "src/b.ts", oid: "bbb222" },
706 ],
707 },
708 },
709 },
710 },
711 }),
712 ),
713 );
714
715 expect([...blobs]).toEqual([
716 ["src/a.ts", "aaa111"],
717 ["src/b.ts", "bbb222"],
718 ]);
719 });
720
721 it("leaves out a node missing either half, which names no version", () => {
722 const blobs = expectBlobs(
723 decodeRepositoryBlobsJson(
724 JSON.stringify({
725 data: {
726 project: {
727 repository: {
728 blobs: {
729 nodes: [
730 { path: "src/a.ts", oid: null },
731 { path: null, oid: "bbb222" },
732 null,
733 { path: "src/c.ts", oid: "ccc333" },
734 ],
735 },
736 },
737 },
738 },
739 }),
740 ),
741 );
742
743 expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]);
744 });
745
746 it("keys a blob by the path the host spelled, spaces and all", () => {
747 // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands
748 // under a key the asked-for path is not spelled with, and the caller fills that path in as
749 // the empty revision: a mark on the file then never compares against the real head blob.
750 const blobs = expectBlobs(
751 decodeRepositoryBlobsJson(
752 JSON.stringify({
753 data: {
754 project: {
755 repository: {
756 blobs: {
757 nodes: [
758 { path: " leading.ts", oid: "aaa111" },
759 { path: "trailing.ts ", oid: "bbb222" },
760 { path: " ", oid: "ccc333" },
761 { path: "", oid: "ddd444" },
762 ],
763 },
764 },
765 },
766 },
767 }),
768 ),
769 );
770
771 expect([...blobs]).toEqual([
772 [" leading.ts", "aaa111"],
773 ["trailing.ts ", "bbb222"],
774 // A name that is only spaces is one Git carries too, so it is a path like any other.
775 [" ", "ccc333"],
776 ]);
777 });
778
779 it("tells a project the reader cannot see from a revision with none of the files", () => {
780 // Null is the query going unanswered. Read as an empty answer it would say the head has none
781 // of the asked-for files, which reports every file a reader has cleared as changed.
782 expect(
783 expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))),
784 ).toBe(null);
785 expect(
786 expectSuccess(
787 decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })),
788 ),
789 ).toBe(null);
790 expect(
791 expectSuccess(
792 decodeRepositoryBlobsJson(
793 JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }),
794 ),
795 ),
796 ).toEqual(new Map());
797 });
798
799 it("fails on output that is not the query's shape", () => {
800 expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false);
801 expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false);
802 });
803});
apps/server/src/pullRequest/gitLabMergeRequestJson.ts+87-4
......@@ -19,6 +19,7 @@ import type {
1919 PullRequestState,
2020} from "@t3tools/contracts";
2121import { TrimmedNonEmptyString } from "@t3tools/contracts";
22import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2223import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2324
2425/**
......@@ -680,8 +681,8 @@ function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): {
680681 readonly to: string;
681682} {
682683 return {
683 from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`,
684 to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`,
684 from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`),
685 to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`),
685686 };
686687}
687688
......@@ -718,11 +719,14 @@ export function decodeMergeRequestDiffsJson(
718719 }
719720 const { from, to } = diffHeaderPaths(value);
720721 const header = [
721 `diff --git a/${value.old_path} b/${value.new_path}`,
722 `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`,
722723 ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []),
723724 ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []),
724725 ...(value.renamed_file === true
725 ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`]
726 ? [
727 `rename from ${quoteGitPatchPath(value.old_path)}`,
728 `rename to ${quoteGitPatchPath(value.new_path)}`,
729 ]
726730 : []),
727731 `--- ${from}`,
728732 `+++ ${to}`,
......@@ -947,3 +951,82 @@ export function decodeOwnAwardIdJson(
947951 }
948952 return Result.succeed(null);
949953}
954
955/**
956 * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking
957 * the tree, since GitLab charges this query by how many paths it is given. A path the revision
958 * does not have comes back missing rather than as an error, which is the answer for a deleted file.
959 */
960export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) {
961 project(fullPath: $fullPath) {
962 repository {
963 blobs(ref: $ref, paths: $paths) {
964 nodes { path oid }
965 }
966 }
967 }
968}`;
969
970const RawRepositoryBlobsSchema = Schema.Struct({
971 data: Schema.Struct({
972 project: Schema.NullOr(
973 Schema.Struct({
974 repository: Schema.optional(
975 Schema.NullOr(
976 Schema.Struct({
977 blobs: Schema.optional(
978 Schema.NullOr(
979 Schema.Struct({
980 nodes: Schema.optional(
981 Schema.NullOr(
982 Schema.Array(
983 Schema.NullOr(
984 Schema.Struct({
985 path: Schema.optional(Schema.NullOr(Schema.String)),
986 oid: Schema.optional(Schema.NullOr(Schema.String)),
987 }),
988 ),
989 ),
990 ),
991 ),
992 }),
993 ),
994 ),
995 }),
996 ),
997 ),
998 }),
999 ),
1000 }),
1001});
1002
1003const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema);
1004
1005/**
1006 * Blob ids by path, or null where GitLab did not answer the query at all (a project the token
1007 * cannot see, or a repository with no blobs connection). That case must be told apart from an
1008 * empty answer: read as "the revision has none of these files", it would report every cleared
1009 * file as changed again. A node missing either half is left out, since the caller treats an
1010 * absent path as one the revision does not carry.
1011 */
1012export function decodeRepositoryBlobsJson(
1013 raw: string,
1014): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> {
1015 const decoded = decodeRepositoryBlobs(raw);
1016 if (!Result.isSuccess(decoded)) {
1017 return Result.fail(decoded.failure);
1018 }
1019 const nodes = decoded.success.data.project?.repository?.blobs?.nodes;
1020 if (nodes === undefined || nodes === null) return Result.succeed(null);
1021 const blobs = new Map<string, string>();
1022 for (const node of nodes) {
1023 // Not trimmed, unlike everything else read out of this payload: a leading or trailing space
1024 // is a legal part of a file's name, and trimming it would key this map under a name the
1025 // caller's asked-for path never matches.
1026 const path = node?.path;
1027 const oid = trimmed(node?.oid);
1028 if (path === undefined || path === null || path.length === 0 || oid === null) continue;
1029 blobs.set(path, oid);
1030 }
1031 return Result.succeed(blobs);
1032}
apps/server/src/pullRequest/pullRequestViewedFiles.ts created+423
......@@ -0,0 +1,423 @@
1/**
2 * The marks a reader has ticked off, for a host that keeps none of its own, and the held record of
3 * what the head has of those files. The revisions cache is filed here because this is its only
4 * consumer: when a second one appears, export `makeFileRevisions` and split it into its own file.
5 */
6import * as Clock from "effect/Clock";
7import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Semaphore from "effect/Semaphore";
11import {
12 PullRequestOperationError,
13 type PullRequestFilesViewedResult,
14 type PullRequestRef,
15 type PullRequestSetFilesViewedInput,
16} from "@t3tools/contracts";
17
18import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
19import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts";
20import type { PullRequestError, SupportedProject } from "./PullRequestService.ts";
21
22/**
23 * How long the head's version of a file is believed, and how long a held answer stands while the
24 * next one is fetched. This is the host call behind the **Changed** badge alone, so a held answer
25 * costs a badge that is a minute behind rather than a stale tick.
26 */
27const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60);
28const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10);
29export const FILE_REVISIONS_CACHE_CAPACITY = 64;
30
31/**
32 * How many paths one scope's entry carries. Bounds a single scope, not how many scopes are held:
33 * a reader ticking one file after another renews the same scope and grows it without limit
34 * otherwise.
35 */
36export const MAX_FILE_REVISION_PATHS = 1_000;
37
38interface FileRevisionsDependencies {
39 readonly runFork: (effect: Effect.Effect<void>) => unknown;
40 readonly refEpoch: (ref: PullRequestRef) => number;
41 readonly fileRevisionsEpoch: () => number;
42 readonly toPullRequestError: (
43 operation: string,
44 ) => (error: PullRequestProviderError) => PullRequestError;
45}
46
47const makeFileRevisions = (dependencies: FileRevisionsDependencies) => {
48 const { runFork, refEpoch, fileRevisionsEpoch, toPullRequestError } = dependencies;
49 /**
50 * What the head has of the files a reader has marked, held between reads. The entry tracks what
51 * has been asked as well as what was heard, since a path missing from an answer keeps whatever
52 * version was last given for it rather than being cleared.
53 */
54 interface HeldFileRevisions {
55 readonly at: number;
56 readonly asked: ReadonlySet<string>;
57 readonly revisions: ReadonlyMap<string, string>;
58 }
59 const heldFileRevisions = new Map<string, HeldFileRevisions>();
60 const refreshingFileRevisions = new Set<string>();
61
62 /**
63 * Carries the reference's epoch, so whatever moved the head strands what was held (or in
64 * flight) against the old one. Spelled from the project rather than the reference, since a
65 * reference arrives however the client spelled it while the epoch is bumped against the
66 * remote's own spelling.
67 */
68 const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) =>
69 [
70 refEpoch({ ...ref, host: project.host, repository: project.repository }),
71 fileRevisionsEpoch(),
72 ref.projectId,
73 project.repository.trim().toLowerCase(),
74 ref.number,
75 ].join(" ");
76
77 /**
78 * `paths` are what was asked about, and are held as answered for whether the host had a version
79 * for them or not: that is what stops the same question being asked again. A `complete` answer
80 * adds every other path it carries, since a host that read the whole change to answer for one
81 * file has already paid for all of them, and the tick after this one names a file nothing has
82 * asked about yet.
83 */
84 const recordFileRevisions = (
85 key: string,
86 paths: ReadonlyArray<string>,
87 answer: ProviderFileRevisions,
88 ) =>
89 Effect.map(Clock.currentTimeMillis, (at) => {
90 const held = heldFileRevisions.get(key);
91 // Past the stale window the old entry is not worth merging into: it would carry paths
92 // nobody has asked about since, at revisions the head has long moved off.
93 const carried =
94 held !== undefined && at - held.at <= Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)
95 ? held
96 : null;
97 const revisions = new Map(carried?.revisions ?? []);
98 const asked = new Set(carried?.asked ?? []);
99 // The paths asked for go last, so a whole-change answer wider than the cap is trimmed down
100 // to the reader's own files rather than over them.
101 const learned = answer.complete === true ? [...answer.revisions.keys(), ...paths] : paths;
102 for (const path of learned) {
103 // Reinserted rather than added, so what a full entry drops below is the path nobody has
104 // asked about in the longest rather than one just asked for.
105 asked.delete(path);
106 asked.add(path);
107 const revision = answer.revisions.get(path);
108 // A path left out of the answer keeps its last known version rather than being cleared.
109 if (revision !== undefined) {
110 revisions.delete(path);
111 revisions.set(path, revision);
112 }
113 }
114 for (const path of asked) {
115 if (asked.size <= MAX_FILE_REVISION_PATHS) break;
116 asked.delete(path);
117 revisions.delete(path);
118 }
119 heldFileRevisions.delete(key);
120 if (heldFileRevisions.size >= FILE_REVISIONS_CACHE_CAPACITY) {
121 const oldest = heldFileRevisions.keys().next().value;
122 if (oldest !== undefined) heldFileRevisions.delete(oldest);
123 }
124 // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial
125 // answer would let an old revision ride past the point it should have been re-read.
126 const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path))
127 ? at
128 : (carried?.at ?? at);
129 heldFileRevisions.set(key, { at: stamped, asked, revisions });
130 return revisions;
131 });
132
133 /** A held entry that covers every path asked for and is still worth answering from. */
134 const heldFileRevisionsFor = (key: string, paths: ReadonlyArray<string>, now: number) => {
135 const held = heldFileRevisions.get(key);
136 if (held === undefined) return null;
137 // Put back at the end on every read, so the scope a reader is working through is not the one
138 // evicted by a listing walking scopes nobody has open.
139 heldFileRevisions.delete(key);
140 heldFileRevisions.set(key, held);
141 if (now - held.at > Duration.toMillis(FILE_REVISIONS_STALE_WINDOW)) return null;
142 return paths.every((path) => held.asked.has(path)) ? held : null;
143 };
144
145 /**
146 * What the head has of these files, or null where the host cannot say (not an error: the marks
147 * just stop reporting staleness). `held` answers from a stale value and refetches off the
148 * critical path, since a badge a moment behind beats a page that won't paint until the host
149 * answers; `fresh` is for the press itself, which must not store a revision the head already
150 * moved off.
151 */
152 const fileRevisionsOf = (
153 project: SupportedProject,
154 ref: PullRequestRef,
155 paths: ReadonlyArray<string>,
156 operation: string,
157 freshness: "held" | "fresh" = "held",
158 ): Effect.Effect<ReadonlyMap<string, string> | null, PullRequestError> => {
159 const read = project.api.getFileRevisions;
160 if (read === undefined) return Effect.succeed(null);
161 // Suspended, so a held answer costs the host nothing: a provider is free to do its work as
162 // the request is built rather than as the effect is run.
163 const fetch = Effect.suspend(() => {
164 const key = fileRevisionsKey(project, ref);
165 return read({
166 cwd: project.project.workspaceRoot,
167 repository: project.repository,
168 host: project.host,
169 number: ref.number,
170 paths,
171 }).pipe(
172 Effect.mapError(toPullRequestError(operation)),
173 Effect.flatMap((answer) => recordFileRevisions(key, paths, answer)),
174 );
175 });
176 return Effect.flatMap(Clock.currentTimeMillis, (now) => {
177 const key = fileRevisionsKey(project, ref);
178 const held = heldFileRevisionsFor(key, paths, now);
179 if (held === null) return fetch;
180 if (now - held.at <= Duration.toMillis(FILE_REVISIONS_CACHE_TTL))
181 return Effect.succeed(held.revisions);
182 if (freshness === "fresh") return fetch;
183 if (refreshingFileRevisions.has(key)) return Effect.succeed(held.revisions);
184 // Its own fiber rather than a child: the caller has been answered and is gone before this
185 // lands. One at a time per change request, so a page of files costs one host read.
186 return Effect.sync(() => {
187 refreshingFileRevisions.add(key);
188 runFork(
189 Effect.ignore(fetch).pipe(
190 Effect.ensuring(Effect.sync(() => refreshingFileRevisions.delete(key))),
191 ),
192 );
193 }).pipe(Effect.as(held.revisions));
194 });
195 };
196
197 return { fileRevisionsOf } as const;
198};
199
200export interface Dependencies extends FileRevisionsDependencies {
201 readonly filesViewedStore: PullRequestFilesViewed.PullRequestFilesViewedRepository["Service"];
202 readonly requireProject: (
203 ref: PullRequestRef,
204 ) => Effect.Effect<SupportedProject, PullRequestError>;
205 readonly requiredViewerOf: (
206 project: SupportedProject,
207 operation: string,
208 ) => Effect.Effect<string | null, PullRequestError>;
209}
210
211// A plain factory rather than a `Context.Service` (against the preference in
212// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct
213// at one instance per service, and a layer provided at two points would give two of each behind
214// one epoch counter.
215export const make = (dependencies: Dependencies) => {
216 const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies;
217 const { fileRevisionsOf } = makeFileRevisions(dependencies);
218 /**
219 * Which change request's marks, and whose. Provider and host lead the key because the same
220 * repository can exist on more than one install; the reader is part of it because a host's own
221 * record is per-account. A host that names no reader is one reader, not none.
222 */
223 const filesViewedScope = (project: SupportedProject, number: number, viewer: string | null) => ({
224 provider: project.api.kind,
225 host: project.host,
226 repository: project.remote,
227 number,
228 viewer: viewer ?? "",
229 });
230
231 const toFilesViewedStoreError = (operation: string) => (cause: unknown) =>
232 new PullRequestOperationError({
233 operation,
234 detail: "This environment could not reach its record of which files you have seen.",
235 cause,
236 });
237
238 /**
239 * The marks this environment keeps for a host that keeps none of its own. A file the head
240 * still has at the revision it was cleared at is cleared; one the head has moved on from is
241 * reported as changed. Revisions are asked for the marked paths alone, so a reader who has
242 * marked nothing costs no host call.
243 */
244 const environmentFilesViewed = (
245 project: SupportedProject,
246 ref: PullRequestRef,
247 ): Effect.Effect<PullRequestFilesViewedResult, PullRequestError> =>
248 Effect.gen(function* () {
249 const viewer = yield* requiredViewerOf(project, "filesViewed");
250 const held = yield* filesViewedStore
251 .list(filesViewedScope(project, ref.number, viewer))
252 .pipe(Effect.mapError(toFilesViewedStoreError("filesViewed")));
253 const marks = held.files;
254 if (marks.length === 0) return { files: [], truncated: held.truncated };
255 // A host that won't say what its head has costs the marks their staleness (`fileRevisionsOf`
256 // returns null), rather than costing the reader every tick they've made.
257 const revisions = yield* fileRevisionsOf(
258 project,
259 ref,
260 marks.map((mark) => mark.path),
261 "filesViewed",
262 ).pipe(
263 Effect.catch((error) =>
264 Effect.logWarning("reporting viewed files without what the head has of them", {
265 operation: "filesViewed",
266 reason: error._tag,
267 }).pipe(Effect.as(null)),
268 ),
269 );
270 return {
271 files: marks.map((mark) => {
272 // A mark stamped with no baseline holds until the reader presses it again.
273 if (mark.revision === null) return { path: mark.path, state: "viewed" as const };
274 const revision = revisions?.get(mark.path);
275 // A deleted file is answered as the empty revision, matching its stamp, so it stays
276 // cleared; a path the host had no answer for (`undefined`) also holds as cleared.
277 return {
278 path: mark.path,
279 state:
280 revision === undefined || revision === mark.revision
281 ? ("viewed" as const)
282 : ("dismissed" as const),
283 };
284 }),
285 // The store caps marks per scope; a reader over that cap is told so, like a paginated read.
286 truncated: held.truncated,
287 };
288 });
289
290 /**
291 * One environment-backed write at a time per change request. A tick's host round trip is
292 * slower than an untick's, so unordered presses could finish out of order and leave a stale
293 * tick standing over a later untick.
294 */
295 const filesViewedGates = new Map<
296 string,
297 { readonly gate: Semaphore.Semaphore; pending: number }
298 >();
299
300 const inFilesViewedOrder = (
301 project: SupportedProject,
302 number: number,
303 write: Effect.Effect<void, PullRequestError>,
304 ) =>
305 // Suspended rather than generated, so finding the gate, putting it in and taking a place in
306 // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert
307 // lets two presses each make a gate of their own and neither wait on the other.
308 Effect.suspend(() => {
309 const key = `${project.project.id} ${project.remote} ${number}`;
310 const held = filesViewedGates.get(key);
311 const entry = held ?? { gate: Semaphore.makeUnsafe(1), pending: 0 };
312 if (held === undefined) filesViewedGates.set(key, entry);
313 entry.pending += 1;
314 // Dropped once nobody is queued behind it, so a long-lived server does not keep a gate per
315 // change request anyone has ever ticked a file in.
316 return entry.gate
317 .withPermits(1)(write)
318 .pipe(
319 Effect.ensuring(
320 Effect.sync(() => {
321 entry.pending -= 1;
322 if (entry.pending === 0) filesViewedGates.delete(key);
323 }),
324 ),
325 );
326 });
327
328 const environmentSetFilesViewed = (
329 project: SupportedProject,
330 input: PullRequestSetFilesViewedInput,
331 ): Effect.Effect<void, PullRequestError> =>
332 Effect.gen(function* () {
333 const viewer = yield* requiredViewerOf(project, "setFilesViewed");
334 // Only the files being cleared need a revision. An unticked one is about to lose its row,
335 // and what the head has of it changes nothing about deleting it.
336 const cleared = input.files.filter((file) => file.viewed).map((file) => file.path);
337 const revisions =
338 cleared.length === 0
339 ? null
340 : yield* fileRevisionsOf(project, input, cleared, "setFilesViewed", "fresh").pipe(
341 // A host that won't say what its head has costs the press its baseline, not the
342 // press itself: the mark is stored with none and holds until pressed again.
343 Effect.catch((error) =>
344 Effect.logWarning("recording viewed files without what the head has of them", {
345 operation: "setFilesViewed",
346 reason: error._tag,
347 }).pipe(Effect.as(null)),
348 ),
349 );
350 const viewedAt = DateTime.formatIso(yield* DateTime.now);
351 yield* filesViewedStore
352 .set({
353 ...filesViewedScope(project, input.number, viewer),
354 // A path left out of the answer stores with no baseline, not the empty revision, since
355 // the empty revision is itself an answer and would misreport the file once it turns
356 // out to have a version after all.
357 files: input.files.map((file) => ({
358 path: file.path,
359 revision: revisions?.get(file.path) ?? null,
360 viewed: file.viewed,
361 })),
362 viewedAt,
363 })
364 .pipe(Effect.mapError(toFilesViewedStoreError("setFilesViewed")));
365 });
366
367 const filesViewed = (input: PullRequestRef) =>
368 requireProject(input).pipe(
369 Effect.flatMap((project): Effect.Effect<PullRequestFilesViewedResult, PullRequestError> => {
370 const read = project.api.getFilesViewed;
371 if (project.api.capabilities.viewedFiles === "host" && read) {
372 return read({
373 cwd: project.project.workspaceRoot,
374 repository: project.repository,
375 host: project.host,
376 number: input.number,
377 }).pipe(Effect.mapError(toPullRequestError("filesViewed")));
378 }
379 if (project.api.capabilities.viewedFiles === "environment") {
380 return environmentFilesViewed(project, input);
381 }
382 return Effect.fail(
383 new PullRequestOperationError({
384 operation: "filesViewed",
385 detail: "This host does not track which files a reader has seen.",
386 }),
387 );
388 }),
389 );
390
391 const setFilesViewed = (
392 input: PullRequestSetFilesViewedInput,
393 ): Effect.Effect<void, PullRequestError> =>
394 requireProject(input).pipe(
395 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
396 const write = project.api.setFilesViewed;
397 if (project.api.capabilities.viewedFiles === "host" && write) {
398 return write({
399 cwd: project.project.workspaceRoot,
400 repository: project.repository,
401 host: project.host,
402 number: input.number,
403 files: input.files,
404 }).pipe(Effect.mapError(toPullRequestError("setFilesViewed")));
405 }
406 if (project.api.capabilities.viewedFiles === "environment") {
407 return inFilesViewedOrder(
408 project,
409 input.number,
410 environmentSetFilesViewed(project, input),
411 );
412 }
413 return Effect.fail(
414 new PullRequestOperationError({
415 operation: "setFilesViewed",
416 detail: "This host does not track which files a reader has seen.",
417 }),
418 );
419 }),
420 );
421
422 return { filesViewed, setFilesViewed };
423};
apps/server/src/server.test.ts+5-1
......@@ -755,7 +755,11 @@ const buildAppUnderTest = (options?: {
755755 );
756756
757757 const servedRoutesLayer = HttpRouter.serve(
758 makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)),
758 // Viewed-file marks for a host that keeps none of its own are rows, so the routes want a
759 // database. Its own, in memory: nothing here shares a table with the auth store.
760 makeRoutesLayer.pipe(
761 Layer.provide(Layer.mergeAll(serviceLauncherClientLayer, SqlitePersistenceMemory)),
762 ),
759763 {
760764 disableListenLog: true,
761765 disableLogger: true,
apps/server/src/server.ts+3
......@@ -39,6 +39,7 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts";
3939import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts";
4040import * as PullRequestService from "./pullRequest/PullRequestService.ts";
4141import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
42import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts";
4243import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
4344import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
4445import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
......@@ -329,6 +330,8 @@ const RepositoryIdentityResolverLayerLive = Layer.effect(
329330
330331const PullRequestServiceLive = PullRequestService.layer.pipe(
331332 Layer.provide(PullRequestProviderRegistry.layer),
333 // Where the viewed-file marks live for a host that keeps none of its own.
334 Layer.provide(PullRequestFilesViewed.layer),
332335 Layer.provide(PullRequestReadCache.layer),
333336 Layer.provide(SourceControlProviderRegistryLayerLive),
334337 Layer.provide(SourceControlRateLimit.layer),
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+4-4
......@@ -77,11 +77,11 @@ function encodeAzureDevOpsPathSegment(segment: string): string {
7777}
7878
7979/**
80 * The organization root a REST url belongs to, which is where a browser url and any further
81 * REST call have to be hung. Exported because the pull requests page derives its own urls from
82 * whatever Azure returned rather than from the local remote, whose shape varies.
80 * The organization root a REST url belongs to, which is where a browser url has to be hung when
81 * Azure answered with neither a web link nor a repository url. Read from what Azure returned
82 * rather than from the local remote, whose shape varies.
8383 */
84export function azureDevOpsOrganizationBaseFromRestApiUrl(
84function azureDevOpsOrganizationBaseFromRestApiUrl(
8585 value: string | null | undefined,
8686): string | null {
8787 const rawUrl = trimOptionalString(value);
apps/server/src/ws.ts+13-1
......@@ -2751,6 +2751,18 @@ const makeWsRpcLayer = (
27512751 withPullRequestViewer(input, pullRequests.diffFileContents(input)),
27522752 { "rpc.aggregate": "pull-requests" },
27532753 ),
2754 [WS_METHODS.pullRequestsFilesViewed]: (input) =>
2755 observeRpcEffect(
2756 WS_METHODS.pullRequestsFilesViewed,
2757 withPullRequestViewer(input, pullRequests.filesViewed(input)),
2758 { "rpc.aggregate": "pull-requests" },
2759 ),
2760 [WS_METHODS.pullRequestsSetFilesViewed]: (input) =>
2761 observeRpcEffect(
2762 WS_METHODS.pullRequestsSetFilesViewed,
2763 withPullRequestViewer(input, pullRequests.setFilesViewed(input)),
2764 { "rpc.aggregate": "pull-requests" },
2765 ),
27542766 [WS_METHODS.pullRequestsRunAction]: (input) =>
27552767 observeRpcEffect(
27562768 WS_METHODS.pullRequestsRunAction,
......@@ -2824,7 +2836,7 @@ const makeWsRpcLayer = (
28242836 // A reader asking for fresh host state also wants the thread badges it feeds to
28252837 // catch up, including a merged link the sweep would otherwise never revisit.
28262838 Effect.andThen(
2827 input.reference === undefined
2839 input.reference === undefined || input.filesViewedOnly === true
28282840 ? Effect.void
28292841 : resolvePullRequestSyncKey(input.reference).pipe(
28302842 Effect.flatMap((key) =>
apps/web/src/components/ChatMarkdown.tsx+2-1
......@@ -53,6 +53,7 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li
5353import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
5454import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
5555import * as Cause from "effect/Cause";
56import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
5657import { AsyncResult } from "effect/unstable/reactivity";
5758import React, {
5859 Children,
......@@ -2905,7 +2906,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
29052906 input: {
29062907 projectId: pullRequestProject.id,
29072908 repository:
2908 pullRequestProject.repositoryIdentity?.displayName ??
2909 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
29092910 pullRequestCandidate.repository,
29102911 number: pullRequestCandidate.number,
29112912 },
apps/web/src/components/ChatView.tsx+4-1
......@@ -76,6 +76,7 @@ import {
7676 resolveProjectScripts,
7777} from "@t3tools/shared/projectScripts";
7878import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
79import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
7980import { truncate } from "@t3tools/shared/String";
8081import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
8182import {
......@@ -4663,7 +4664,9 @@ export default function ChatView(props: ChatViewProps) {
46634664 const hasLinkedPullRequestDetail = activeThreadMetadata?.linkedPullRequest != null;
46644665 const linkedThreadPullRequest =
46654666 activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null;
4666 const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
4667 const activeProjectRepository = sourceControlRepositorySelector(
4668 activeProject?.repositoryIdentity,
4669 );
46674670 const linkedThreadPullRequestKey = linkedThreadPullRequest
46684671 ? JSON.stringify([
46694672 linkedThreadPullRequest.projectId,
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+187-30
......@@ -17,6 +17,7 @@ import {
1717 ChevronsUpDownIcon,
1818 Columns2Icon,
1919 FolderTreeIcon,
20 InfoIcon,
2021 MessageSquareIcon,
2122 MessageSquareOffIcon,
2223 Rows3Icon,
......@@ -44,6 +45,7 @@ import {
4445 resolveFileDiffPreviousPath,
4546 type RenderablePatch,
4647} from "~/lib/diffRendering";
48import { APP_BASE_NAME } from "~/branding";
4749import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
4850import { cn } from "~/lib/utils";
4951import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents";
......@@ -63,6 +65,7 @@ import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal";
6365import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";
6466import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";
6567import { Button } from "../ui/button";
68import { Checkbox } from "../ui/checkbox";
6669import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
6770import {
6871 DropdownMenu,
......@@ -78,9 +81,11 @@ import { PullRequestReviewBar } from "./PullRequestReviewBar";
7881import {
7982 isFileDiffCollapsed,
8083 isLineInFileDiff,
84 toggleFileDiffFoldForViewed,
8185 type DiffFoldOverride,
8286} from "./pullRequestDiff.logic";
8387import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation";
88import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed";
8489import {
8590 nextPendingReviewCommentId,
8691 pullRequestReviewKey,
......@@ -223,6 +228,8 @@ function PullRequestCodeTab({
223228 const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE);
224229 /** Set once the reader has asked for every file at once, until they pick a file apart again. */
225230 const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null);
231 const effectiveFoldOverride =
232 foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded");
226233 const diffLayout = settings.diffLayout;
227234 const updateClientSettings = useUpdateClientSettings();
228235 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
......@@ -331,13 +338,6 @@ function PullRequestCodeTab({
331338 input: { ...reference, ...(commit === null ? {} : { commit }) },
332339 }),
333340 );
334 const appliedRefreshToken = useRef(refreshToken);
335 useEffect(() => {
336 if (appliedRefreshToken.current === refreshToken) return;
337 appliedRefreshToken.current = refreshToken;
338 setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES });
339 refreshFirstDiffPage();
340 }, [refreshToken, scopeKey, refreshFirstDiffPage]);
341341 const reviewKey = referenceKey;
342342 const pendingComments = usePendingReviewComments(reference);
343343 const addComment = usePullRequestReviewStore((store) => store.addComment);
......@@ -410,6 +410,35 @@ function PullRequestCodeTab({
410410 ),
411411 [parsedSlices],
412412 );
413 const filePaths = useMemo(() => files.map((file) => resolveFileDiffPath(file)), [files]);
414 // Offered under a commit scope as well as from the whole change, because reading a change one
415 // commit at a time is what the scope is for. The tick is kept against the change request rather
416 // than the scope it was made in, so clearing a file here clears it everywhere.
417 const viewedFilesStore = detail.capabilities.viewedFiles;
418 const filesViewed = usePullRequestFilesViewed({
419 environmentId,
420 reference,
421 enabled: viewedFilesStore !== undefined,
422 paths: filePaths,
423 });
424 const {
425 setViewed,
426 refresh: refreshFilesViewed,
427 enabled: filesViewedEnabled,
428 isViewed: isFileViewed,
429 isStale: isFileViewedStale,
430 } = filesViewed;
431 // The button goes around the host's cache, so everything the tab reads from it starts over:
432 // the diff from its first page, and with it the ticks, which a push since the last read can
433 // have marked as standing against an older version of the file.
434 const appliedRefreshToken = useRef(refreshToken);
435 useEffect(() => {
436 if (appliedRefreshToken.current === refreshToken) return;
437 appliedRefreshToken.current = refreshToken;
438 setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES });
439 refreshFirstDiffPage();
440 refreshFilesViewed();
441 }, [refreshToken, scopeKey, refreshFirstDiffPage, refreshFilesViewed]);
413442 const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null;
414443 // What a slice withheld: the host declining to inline part of it, or a patch the viewer could
415444 // not structure and so dropped. Neither says anything about there being more to fetch.
......@@ -442,7 +471,9 @@ function PullRequestCodeTab({
442471 return placed;
443472 }, [commit, detail.reviewThreads, files]);
444473
445 const items = useMemo<CodeViewDiffItem<ReviewAnnotationGroup>[]>(
474 // Hashing what the annotations show is the costly part of an item's version, and none of it
475 // moves when a file is ticked or folded, so it is kept apart from the two that do.
476 const annotatedFiles = useMemo(
446477 () =>
447478 files.map((fileDiff) => {
448479 const fileKey = buildFileDiffRenderKey(fileDiff);
......@@ -484,27 +515,20 @@ function PullRequestCodeTab({
484515 groupAt(anchor.side, anchor.line).draft = true;
485516 }
486517
487 const collapsed = isFileDiffCollapsed(
488 fileKey,
489 foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"),
490 toggledFiles,
491 );
492
493518 const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({
494519 side: toViewerSide(group.side),
495520 lineNumber: group.line,
496521 metadata: { threads: group.threads, pending: group.pending, draft: group.draft },
497522 }));
498523 return {
499 id: fileKey,
500 type: "diff" as const,
524 fileKey,
525 path,
501526 fileDiff,
502527 annotations,
503 collapsed,
504528 // The viewer re-renders an item only when its version changes, so everything the
505529 // annotations show has to be part of it.
506 version: fnv1a32(
507 `${collapsed ? "1" : "0"}:${annotations
530 annotationsVersion: fnv1a32(
531 annotations
508532 .map(
509533 ({ side, lineNumber, metadata }) =>
510534 `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending
......@@ -529,19 +553,37 @@ function PullRequestCodeTab({
529553 )
530554 .join(",")}`,
531555 )
532 .join("|")}`,
556 .join("|"),
533557 ),
534558 };
535559 }),
560 [commit, detail.reviewThreads, draft, files, pendingComments, placedThreadIds],
561 );
562
563 const items = useMemo<CodeViewDiffItem<ReviewAnnotationGroup>[]>(
564 () =>
565 annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => {
566 const collapsed = isFileDiffCollapsed(fileKey, effectiveFoldOverride, toggledFiles);
567 // Ticking a file that is already folded changes no fold, so without this the box on
568 // screen would keep saying the opposite of what the count says.
569 const viewedMark = filesViewedEnabled
570 ? `e${isFileViewed(path) ? "v" : ""}${isFileViewedStale(path) ? "s" : ""}`
571 : "";
572 return {
573 id: fileKey,
574 type: "diff" as const,
575 fileDiff,
576 annotations,
577 collapsed,
578 version: fnv1a32(`${collapsed ? "1" : "0"}:${viewedMark}:${annotationsVersion}`),
579 };
580 }),
536581 [
537 commit,
538 detail.reviewThreads,
539 draft,
540 files,
541 foldOverride,
542 pendingComments,
543 placedThreadIds,
544 settings.diffFilesCollapsed,
582 annotatedFiles,
583 filesViewedEnabled,
584 effectiveFoldOverride,
585 isFileViewed,
586 isFileViewedStale,
545587 toggledFiles,
546588 ],
547589 );
......@@ -606,6 +648,19 @@ function PullRequestCodeTab({
606648 [],
607649 );
608650
651 // The tick and the fold are one gesture: clearing a file puts it away, un-clearing brings it
652 // back. Folding is still held as the reader's difference from the toolbar's default rather
653 // than derived from what has been ticked, so folding everything ticks nothing off.
654 const setFileViewed = useCallback(
655 (fileKey: string, path: string, viewed: boolean) => {
656 setViewed(path, viewed);
657 setToggledFiles((current) =>
658 toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current),
659 );
660 },
661 [effectiveFoldOverride, setViewed],
662 );
663
609664 const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey);
610665 const revealFile = useCallback(
611666 (path: string) => {
......@@ -743,6 +798,14 @@ function PullRequestCodeTab({
743798 [toggleFile],
744799 );
745800
801 // Read through refs rather than closed over. The viewer memoizes each visible file's header
802 // portal on the callback below, so a fresh identity on every tick, and on every refresh of the
803 // host's answer, would rebuild every header on screen.
804 const filesViewedRef = useRef(filesViewed);
805 filesViewedRef.current = filesViewed;
806 const setFileViewedRef = useRef(setFileViewed);
807 setFileViewedRef.current = setFileViewed;
808
746809 const renderHeaderMetadata = useCallback(
747810 (item: CodeViewItem<ReviewAnnotationGroup>) => {
748811 if (item.type !== "diff") return null;
......@@ -752,17 +815,52 @@ function PullRequestCodeTab({
752815 additions += hunk.additionLines;
753816 deletions += hunk.deletionLines;
754817 }
818 const path = resolveFileDiffPath(item.fileDiff);
755819 if (additions === 0 && deletions === 0) {
756 const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff));
820 const withheld = omittedFileStats.get(path);
757821 if (withheld) ({ additions, deletions } = withheld);
758822 }
759 return (
823 const stat = (
760824 <PullRequestDiffStat
761825 additions={additions}
762826 deletions={deletions}
763827 className="font-mono text-[11px]"
764828 />
765829 );
830 const viewedFiles = filesViewedRef.current;
831 if (!viewedFiles.enabled) return stat;
832 const viewed = viewedFiles.isViewed(path);
833 const stale = viewedFiles.isStale(path);
834 return (
835 <span className="flex items-center gap-3">
836 {stat}
837 {/* The header itself folds the file, so the tick keeps its press to itself. The
838 attribute is what the header's capture listener looks for. */}
839 <label
840 data-viewed-toggle=""
841 className="flex cursor-pointer select-none items-center gap-1.5 text-[11px] text-muted-foreground"
842 onClick={(event) => event.stopPropagation()}
843 >
844 <Checkbox
845 aria-label={stale ? "Changed" : "Viewed"}
846 checked={viewed}
847 onCheckedChange={(next) => setFileViewedRef.current(item.id, path, next === true)}
848 />
849 {stale ? (
850 <Tooltip>
851 <TooltipTrigger render={<span className="text-amber-600 dark:text-amber-500" />}>
852 Changed
853 </TooltipTrigger>
854 <TooltipPopup side="bottom">
855 This file has been pushed to since you marked it viewed.
856 </TooltipPopup>
857 </Tooltip>
858 ) : (
859 "Viewed"
860 )}
861 </label>
862 </span>
863 );
766864 },
767865 [omittedFileStats],
768866 );
......@@ -1092,6 +1190,62 @@ function PullRequestCodeTab({
10921190 {files.length} {files.length === 1 ? "file" : "files"}
10931191 {nextCursor === null ? "" : "+"}
10941192 </span>
1193 {filesViewed.enabled && files.length > 0 ? (
1194 <span className="flex min-w-0 items-center gap-1 tabular-nums">
1195 {/* Named on a host that keeps no record of its own, so the reader is told whose
1196 ticks these are without having to find the icon beside them. The count holds its
1197 width and the wording gives way, so the narrow right panel keeps its controls. */}
1198 <span className="shrink-0">
1199 {filesViewed.viewedCount} / {files.length}
1200 </span>
1201 <span className="truncate">
1202 {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"}
1203 </span>
1204 {viewedFilesStore === "environment" ? (
1205 <Tooltip>
1206 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
1207 <InfoIcon
1208 aria-label="These ticks are kept here, not on the host"
1209 className="text-muted-foreground size-3.5"
1210 />
1211 </TooltipTrigger>
1212 <TooltipPopup side="bottom">
1213 This host keeps no shared record of which files you have read, so these ticks
1214 are kept by this environment. They follow you between the apps connected to it,
1215 but the host's own web UI will not show them.
1216 </TooltipPopup>
1217 </Tooltip>
1218 ) : null}
1219 {filesViewed.error !== null ? (
1220 <Tooltip>
1221 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
1222 <TriangleAlertIcon
1223 aria-label="Your ticks could not be read"
1224 className="size-3.5 text-amber-600 dark:text-amber-500"
1225 />
1226 </TooltipTrigger>
1227 <TooltipPopup side="bottom">
1228 The boxes below are whatever was last read, and empty if nothing has been read
1229 yet. {filesViewed.error}
1230 </TooltipPopup>
1231 </Tooltip>
1232 ) : null}
1233 {filesViewed.truncated ? (
1234 <Tooltip>
1235 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
1236 <TriangleAlertIcon
1237 aria-label="This count covers only part of the change"
1238 className="size-3.5 text-amber-600 dark:text-amber-500"
1239 />
1240 </TooltipTrigger>
1241 <TooltipPopup side="bottom">
1242 This change has more files than the host will report ticks for in one read, so
1243 the count is short and some boxes below start empty.
1244 </TooltipPopup>
1245 </Tooltip>
1246 ) : null}
1247 </span>
1248 ) : null}
10951249 {withheldContent ? (
10961250 <Tooltip>
10971251 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
......@@ -1375,6 +1529,9 @@ function PullRequestCodeTab({
13751529 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
13761530 return;
13771531 }
1532 // A label answers for the control it names and this listener runs before it hears
1533 // anything, so stopping the press keeps the header from folding what the tick folds.
1534 if (node.hasAttribute("data-viewed-toggle")) return;
13781535 if (node.hasAttribute("data-diffs-header")) {
13791536 const filePath = node.querySelector("[data-title]")?.textContent?.trim();
13801537 if (filePath === undefined || filePath === "") return;
apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts+32-1
......@@ -1,7 +1,11 @@
11import type { FileDiffMetadata } from "@pierre/diffs";
22import { describe, expect, it } from "vite-plus/test";
33
4import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic";
4import {
5 isFileDiffCollapsed,
6 isLineInFileDiff,
7 toggleFileDiffFoldForViewed,
8} from "./pullRequestDiff.logic";
59
610/** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */
711function fileWithHunks(
......@@ -79,3 +83,30 @@ describe("isFileDiffCollapsed", () => {
7983 expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false);
8084 });
8185});
86
87describe("toggleFileDiffFoldForViewed", () => {
88 it("puts a file away when it is ticked off", () => {
89 // Files start expanded, so ticking one off is the case that has somewhere to go.
90 expect([...toggleFileDiffFoldForViewed("a.ts", true, null, new Set())]).toEqual(["a.ts"]);
91 });
92
93 it("brings a file back when the tick is taken off", () => {
94 expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set(["a.ts"]))]).toEqual([]);
95 });
96
97 it("leaves the fold alone when it already says what the tick does", () => {
98 const folded = new Set(["a.ts"]);
99 expect(toggleFileDiffFoldForViewed("a.ts", true, null, folded)).toBe(folded);
100 });
101
102 it("moves against whatever the toolbar last asked for", () => {
103 // Everything is open, so ticking a file off has to fold that one against the default.
104 expect([...toggleFileDiffFoldForViewed("a.ts", true, "expanded", new Set())]).toEqual(["a.ts"]);
105 expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0);
106 });
107
108 it("touches only the file that was ticked", () => {
109 const toggled = new Set(["a.ts", "b.ts"]);
110 expect([...toggleFileDiffFoldForViewed("a.ts", false, null, toggled)]).toEqual(["b.ts"]);
111 });
112});
apps/web/src/components/pullRequest/pullRequestDiff.logic.ts+21
......@@ -41,3 +41,24 @@ export function isFileDiffCollapsed(
4141 const foldedByDefault = foldOverride === "folded";
4242 return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault;
4343}
44
45/**
46 * The reader's fold choices after a file was ticked off, or put back.
47 *
48 * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if
49 * the reader had pressed the chevron themselves, which keeps folding a difference from what the
50 * toolbar last asked, and so keeps "collapse all" from ticking anything off.
51 */
52export function toggleFileDiffFoldForViewed(
53 fileKey: string,
54 viewed: boolean,
55 foldOverride: DiffFoldOverride,
56 toggledFileKeys: ReadonlySet<string>,
57): ReadonlySet<string> {
58 if (isFileDiffCollapsed(fileKey, foldOverride, toggledFileKeys) === viewed)
59 return toggledFileKeys;
60 const next = new Set(toggledFileKeys);
61 if (next.has(fileKey)) next.delete(fileKey);
62 else next.add(fileKey);
63 return next;
64}
apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts created+174
......@@ -0,0 +1,174 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 countViewedFiles,
5 isFileViewed,
6 isStaleViewedState,
7 revertFileViewedOverlay,
8 settleFileViewedOverlay,
9 toFileViewedBatch,
10 toFileViewedStates,
11 type FileViewedOverlay,
12} from "./pullRequestFilesViewed.logic";
13
14const NO_OVERLAY: FileViewedOverlay = new Map();
15const NOTHING_PENDING: ReadonlySet<string> = new Set();
16const NOTHING_ANSWERED: ReadonlySet<string> = new Set();
17
18const states = toFileViewedStates({
19 files: [
20 { path: "a.ts", state: "viewed" },
21 { path: "b.ts", state: "unviewed" },
22 { path: "c.ts", state: "dismissed" },
23 ],
24 truncated: false,
25});
26
27describe("isFileViewed", () => {
28 it("follows the host for a file the reader has not pressed", () => {
29 expect(isFileViewed("a.ts", states, NO_OVERLAY)).toBe(true);
30 expect(isFileViewed("b.ts", states, NO_OVERLAY)).toBe(false);
31 });
32
33 it("reads a file pushed to since it was cleared as unread", () => {
34 expect(isFileViewed("c.ts", states, NO_OVERLAY)).toBe(false);
35 expect(isStaleViewedState(states?.get("c.ts"))).toBe(true);
36 expect(isStaleViewedState(states?.get("a.ts"))).toBe(false);
37 });
38
39 it("shows the press ahead of the host's answer", () => {
40 expect(isFileViewed("b.ts", states, new Map([["b.ts", true]]))).toBe(true);
41 expect(isFileViewed("a.ts", states, new Map([["a.ts", false]]))).toBe(false);
42 });
43
44 it("answers a file the host has said nothing about, before its answer arrives", () => {
45 expect(isFileViewed("z.ts", null, NO_OVERLAY)).toBe(false);
46 expect(isFileViewed("z.ts", null, new Map([["z.ts", true]]))).toBe(true);
47 });
48});
49
50describe("countViewedFiles", () => {
51 it("counts only the files on screen, presses included", () => {
52 expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, NO_OVERLAY)).toBe(1);
53 expect(countViewedFiles(["a.ts", "b.ts", "c.ts"], states, new Map([["b.ts", true]]))).toBe(2);
54 // A file the host knows about but the diff has not paged in yet is not counted.
55 expect(countViewedFiles(["b.ts"], states, NO_OVERLAY)).toBe(0);
56 });
57});
58
59describe("settleFileViewedOverlay", () => {
60 it("drops a press the host has caught up on", () => {
61 const settled = settleFileViewedOverlay(
62 new Map([["a.ts", true]]),
63 states,
64 NOTHING_PENDING,
65 NOTHING_ANSWERED,
66 );
67 expect(settled.size).toBe(0);
68 });
69
70 it("keeps a press the host still disagrees with", () => {
71 const overlay = new Map([["b.ts", true]]);
72 expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING, NOTHING_ANSWERED)).toBe(
73 overlay,
74 );
75 });
76
77 it("keeps a press the host cannot have heard yet", () => {
78 // An answer already on its way when the file was un-ticked would otherwise put the tick back.
79 const overlay = new Map([["a.ts", false]]);
80 const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"]), NOTHING_ANSWERED);
81 expect(settled.get("a.ts")).toBe(false);
82 });
83
84 it("settles a file pushed to since it was cleared against un-ticking it", () => {
85 const settled = settleFileViewedOverlay(
86 new Map([["c.ts", false]]),
87 states,
88 NOTHING_PENDING,
89 NOTHING_ANSWERED,
90 );
91 expect(settled.size).toBe(0);
92 });
93
94 it("drops a tick once a read has answered for it, against what the reader pressed", () => {
95 // The tick landed, the file was pushed to before the read that followed it came back, and the
96 // host answers `dismissed`. Holding the tick would hide that push for as long as the tab
97 // stayed open, and no refresh would recover it: every later answer says `dismissed` too.
98 const settled = settleFileViewedOverlay(
99 new Map([["c.ts", true]]),
100 states,
101 NOTHING_PENDING,
102 new Set(["c.ts"]),
103 );
104 expect(settled.size).toBe(0);
105 expect(isFileViewed("c.ts", states, settled)).toBe(false);
106 expect(isStaleViewedState(states?.get("c.ts"))).toBe(true);
107 });
108
109 it("holds a press made since the read that would otherwise answer for it", () => {
110 const overlay = new Map([["c.ts", true]]);
111 const settled = settleFileViewedOverlay(overlay, states, new Set(["c.ts"]), new Set(["c.ts"]));
112 expect(settled.get("c.ts")).toBe(true);
113 });
114
115 it("holds everything until the host has answered at all", () => {
116 const overlay = new Map([["a.ts", true]]);
117 expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING, NOTHING_ANSWERED)).toBe(overlay);
118 });
119});
120
121describe("toFileViewedBatch", () => {
122 it("carries both directions in one batch", () => {
123 expect(
124 toFileViewedBatch(
125 new Map([
126 ["a.ts", false],
127 ["b.ts", true],
128 ]),
129 ),
130 ).toEqual([
131 { path: "a.ts", viewed: false },
132 { path: "b.ts", viewed: true },
133 ]);
134 });
135});
136
137describe("revertFileViewedOverlay", () => {
138 const batch = [
139 { path: "a.ts", viewed: true },
140 { path: "b.ts", viewed: false },
141 ];
142 const both = new Set(["a.ts", "b.ts"]);
143
144 it("puts the checkbox back to the host's answer for everything the request answers for", () => {
145 const overlay = new Map([
146 ["a.ts", true],
147 ["b.ts", false],
148 ]);
149 expect(revertFileViewedOverlay(overlay, batch, both).size).toBe(0);
150 });
151
152 it("leaves a press the reader made after the request went out", () => {
153 // The second press is waiting on a flush of its own, so the first one failing says nothing
154 // about it.
155 const overlay = new Map([
156 ["a.ts", false],
157 ["b.ts", false],
158 ]);
159 const reverted = revertFileViewedOverlay(overlay, batch, new Set(["b.ts"]));
160 expect([...reverted]).toEqual([["a.ts", false]]);
161 });
162
163 it("leaves a path a later request took over, even pressed the same way", () => {
164 // Both requests carry `a.ts` as viewed, so the value cannot tell them apart. The later one
165 // owns the path now and is the one that answers for it.
166 const overlay = new Map([["a.ts", true]]);
167 expect(revertFileViewedOverlay(overlay, batch, new Set(["b.ts"]))).toBe(overlay);
168 });
169
170 it("leaves a path the request never carried", () => {
171 const overlay = new Map([["c.ts", true]]);
172 expect(revertFileViewedOverlay(overlay, batch, both)).toBe(overlay);
173 });
174});
apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.ts created+100
......@@ -0,0 +1,100 @@
1import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts";
2
3/** What the host last said about each file, by path. Absent means the host said nothing. */
4export type FileViewedStates = ReadonlyMap<string, PullRequestFileViewedState>;
5
6/** Presses the host has not confirmed yet, by path. */
7export type FileViewedOverlay = ReadonlyMap<string, boolean>;
8
9export function toFileViewedStates(
10 result: PullRequestFilesViewedResult | null,
11): FileViewedStates | null {
12 if (result === null) return null;
13 return new Map(result.files.map((file) => [file.path, file.state]));
14}
15
16/**
17 * Whether a file counts as seen. `dismissed` is the host saying it has been pushed to since the
18 * reader cleared it, which reads as unseen: the point of the tick is that the code behind it has
19 * been looked at, and it is not the same code any more.
20 */
21function isViewedState(state: PullRequestFileViewedState | undefined): boolean {
22 return state === "viewed";
23}
24
25/** Whether the file was cleared and has since moved, which the header says out loud. */
26export function isStaleViewedState(state: PullRequestFileViewedState | undefined): boolean {
27 return state === "dismissed";
28}
29
30/** The press the reader made if it has not landed, and the host's answer otherwise. */
31export function isFileViewed(
32 path: string,
33 states: FileViewedStates | null,
34 overlay: FileViewedOverlay,
35): boolean {
36 const pressed = overlay.get(path);
37 return pressed ?? isViewedState(states?.get(path));
38}
39
40export function countViewedFiles(
41 paths: ReadonlyArray<string>,
42 states: FileViewedStates | null,
43 overlay: FileViewedOverlay,
44): number {
45 return paths.reduce(
46 (total, path) => (isFileViewed(path, states, overlay) ? total + 1 : total),
47 0,
48 );
49}
50
51/**
52 * The overlay with everything the host has caught up on removed. A press is held until a read that
53 * could have seen it comes back, rather than cleared when the write succeeds: that read is a
54 * separate round trip, and dropping the press in between flashes the checkbox back.
55 *
56 * `pending` are the paths whose press the host cannot have heard yet, which an answer already on
57 * its way must not overrule. `answered` are the paths a read has landed for since their write was
58 * acknowledged, and those go on that read alone, including where it contradicts the press: a tick
59 * held over a `dismissed` would hide a file pushed to since, with no refresh able to recover it.
60 */
61export function settleFileViewedOverlay(
62 overlay: FileViewedOverlay,
63 states: FileViewedStates | null,
64 pending: ReadonlySet<string>,
65 answered: ReadonlySet<string>,
66): FileViewedOverlay {
67 if (states === null || overlay.size === 0) return overlay;
68 const next = new Map(overlay);
69 for (const [path, pressed] of overlay) {
70 if (pending.has(path)) continue;
71 if (answered.has(path) || isViewedState(states.get(path)) === pressed) next.delete(path);
72 }
73 return next.size === overlay.size ? overlay : next;
74}
75
76/**
77 * The overlay with a failed request's presses taken back. `owned` are the paths that request still
78 * answers for, which keeps a failure from reaching past its own presses: a path pressed again
79 * since belongs to a later request or to the next flush, and putting that box back to the host's
80 * answer would take a press out from under the reader's hand.
81 */
82export function revertFileViewedOverlay(
83 overlay: FileViewedOverlay,
84 batch: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>,
85 owned: ReadonlySet<string>,
86): FileViewedOverlay {
87 const next = new Map(overlay);
88 for (const { path, viewed } of batch) {
89 if (!owned.has(path)) continue;
90 if (next.get(path) === viewed) next.delete(path);
91 }
92 return next.size === overlay.size ? overlay : next;
93}
94
95/** The presses in an overlay as the batch the host is told about. */
96export function toFileViewedBatch(
97 overlay: FileViewedOverlay,
98): ReadonlyArray<{ readonly path: string; readonly viewed: boolean }> {
99 return [...overlay].map(([path, viewed]) => ({ path, viewed }));
100}
apps/web/src/components/pullRequest/usePullRequestFilesViewed.test.tsx created+161
......@@ -0,0 +1,161 @@
1import {
2 EnvironmentId,
3 ProjectId,
4 type PullRequestFilesViewedResult,
5 type PullRequestRef,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/unstable/reactivity";
8import { act, StrictMode } from "react";
9import { create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({
13 host: { data: null as unknown, refresh: vi.fn() },
14 setFilesViewed: vi.fn(),
15 toastAdd: vi.fn(),
16}));
17
18vi.mock("~/state/pullRequests", () => ({
19 pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} },
20}));
21vi.mock("~/state/query", () => ({
22 useEnvironmentQuery: () => ({
23 data: host.data,
24 error: null,
25 isPending: false,
26 isSuccess: true,
27 refresh: host.refresh,
28 }),
29}));
30vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed }));
31vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } }));
32
33import {
34 usePullRequestFilesViewed,
35 type PullRequestFilesViewedView,
36} from "./usePullRequestFilesViewed";
37
38const environmentId = EnvironmentId.make("pr-files-viewed-audit");
39const reference: PullRequestRef = {
40 projectId: ProjectId.make("project-a"),
41 repository: "acme/web",
42 number: 42,
43};
44const paths = ["a.ts"];
45
46/** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */
47function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult {
48 return { files: [{ path: "a.ts", state }], truncated: false };
49}
50
51let renderer: ReactTestRenderer | null = null;
52
53function Probe(_props: { readonly view: PullRequestFilesViewedView }) {
54 return null;
55}
56
57function Surface() {
58 const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths });
59 return <Probe view={view} />;
60}
61
62function view(): PullRequestFilesViewedView {
63 return renderer!.root.findByType(Probe).props.view;
64}
65
66/** The host's next answer landing, which is what a `refresh` ends in. */
67async function reads(state: "unviewed" | "viewed" | "dismissed") {
68 host.data = answer(state);
69 await act(async () =>
70 renderer!.update(
71 <StrictMode>
72 <Surface />
73 </StrictMode>,
74 ),
75 );
76}
77
78beforeEach(async () => {
79 vi.useFakeTimers();
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 host.data = answer("unviewed");
82 host.refresh.mockReset();
83 toastAdd.mockReset();
84 setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined));
85 act(() => {
86 renderer = create(
87 <StrictMode>
88 <Surface />
89 </StrictMode>,
90 );
91 });
92});
93
94afterEach(async () => {
95 await act(async () => renderer?.unmount());
96 renderer = null;
97 vi.useRealTimers();
98 vi.unstubAllGlobals();
99});
100
101describe("a mark whose file was pushed to before the read that followed it", () => {
102 it("gives way to the host and shows the file as changed", async () => {
103 view().setViewed("a.ts", true);
104 await act(async () => vi.advanceTimersByTimeAsync(500));
105 expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({
106 environmentId,
107 input: { ...reference, files: [{ path: "a.ts", viewed: true }] },
108 });
109 expect(host.refresh).toHaveBeenCalled();
110
111 // The push landed between the write and this read, so the host answers `dismissed` rather
112 // than the `viewed` the press asked for.
113 await reads("dismissed");
114
115 expect(view().isViewed("a.ts")).toBe(false);
116 expect(view().isStale("a.ts")).toBe(true);
117 expect(view().viewedCount).toBe(0);
118 });
119
120 it("stays given way to on every later read, having nothing left to recover", async () => {
121 view().setViewed("a.ts", true);
122 await act(async () => vi.advanceTimersByTimeAsync(500));
123 await reads("dismissed");
124
125 view().refresh();
126 await reads("dismissed");
127
128 expect(view().isViewed("a.ts")).toBe(false);
129 expect(view().isStale("a.ts")).toBe(true);
130 });
131});
132
133describe("a mark the host has not answered for yet", () => {
134 it("holds the press while the write is still out", async () => {
135 let land = (_result: unknown) => {};
136 setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve)));
137
138 view().setViewed("a.ts", true);
139 await act(async () => vi.advanceTimersByTimeAsync(500));
140
141 // An answer already on its way when the box was ticked must not put it back.
142 await reads("unviewed");
143 expect(view().isViewed("a.ts")).toBe(true);
144
145 await act(async () => land(AsyncResult.success(undefined)));
146 expect(view().isViewed("a.ts")).toBe(true);
147 });
148
149 it("holds a press made since the read that would otherwise answer for it", async () => {
150 view().setViewed("a.ts", true);
151 await act(async () => vi.advanceTimersByTimeAsync(500));
152
153 // Pressed again before the post-write read came back. That press is the one on screen, and
154 // the read that answers for the first one says nothing about it.
155 view().setViewed("a.ts", true);
156 await reads("dismissed");
157
158 expect(view().isViewed("a.ts")).toBe(true);
159 expect(view().isStale("a.ts")).toBe(false);
160 });
161});
apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts created+211
......@@ -0,0 +1,211 @@
1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5import { pullRequestEnvironment } from "~/state/pullRequests";
6import { useEnvironmentQuery } from "~/state/query";
7import { useAtomCommand } from "~/state/use-atom-command";
8
9import { toastManager } from "../ui/toast";
10import {
11 countViewedFiles,
12 isFileViewed,
13 isStaleViewedState,
14 revertFileViewedOverlay,
15 settleFileViewedOverlay,
16 toFileViewedBatch,
17 toFileViewedStates,
18 type FileViewedOverlay,
19 type FileViewedStates,
20} from "./pullRequestFilesViewed.logic";
21
22/**
23 * How long presses gather before the host is told. Long enough that ticking down a file list
24 * costs one request rather than one per file, short enough that a reader who ticks one file and
25 * closes the tab has already been recorded.
26 */
27const FLUSH_DELAY_MS = 400;
28
29const NO_OVERLAY: FileViewedOverlay = new Map();
30
31export interface PullRequestFilesViewedView {
32 /** Whether anything remembers this at all, which is what hides the whole control. */
33 readonly enabled: boolean;
34 readonly isViewed: (path: string) => boolean;
35 /** This file has been pushed to since it was cleared. */
36 readonly isStale: (path: string) => boolean;
37 readonly setViewed: (path: string, viewed: boolean) => void;
38 /** How many of the files on screen are ticked off. */
39 readonly viewedCount: number;
40 /** The host had more files than the read covered, so the count above may be short. */
41 readonly truncated: boolean;
42 /**
43 * Why the marks could not be read, when they could not. The boxes fall back to the last answer
44 * there was, or to empty when there has not been one, and neither of those says so on its own:
45 * a reader who sees every box unticked has no way to tell a fresh review from a failed read.
46 */
47 readonly error: string | null;
48 /** Re-ask the host, for the page's refresh button, which goes around the host's cache. */
49 readonly refresh: () => void;
50}
51
52/**
53 * Which files this reader has already cleared. The marks live on the server rather than in this
54 * tab, so a review carried on from another machine picks up where it was left. Presses show
55 * immediately and are held over the server's answer until a read that could have seen them comes
56 * back, so the checkbox never waits on a round trip and never outlasts the record behind it.
57 */
58export function usePullRequestFilesViewed(options: {
59 readonly environmentId: EnvironmentId;
60 readonly reference: PullRequestRef;
61 readonly enabled: boolean;
62 /** The paths on screen, which is what the counter counts. */
63 readonly paths: ReadonlyArray<string>;
64}): PullRequestFilesViewedView {
65 const { environmentId, reference, enabled, paths } = options;
66 const query = useEnvironmentQuery(
67 enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null,
68 );
69 const refresh = query.refresh;
70 // `query.data` holds the last answer through a failure, so the boxes stay where the host last
71 // put them rather than emptying under the reader; the error travels with them, because ticks
72 // that stopped being refreshed look exactly like ticks that are current.
73 const states = useMemo(() => toFileViewedStates(query.data), [query.data]);
74 const truncated = query.data?.truncated === true;
75 const error = query.error;
76 const [overlay, setOverlay] = useState<FileViewedOverlay>(NO_OVERLAY);
77 const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, {
78 reportFailure: false,
79 });
80
81 // Presses waiting for the next flush, and, for every path a request is already carrying, which
82 // request that is. A path pressed again while its request is out belongs to the later request
83 // from then on, and the earlier one stops answering for it. Both are refs rather than state:
84 // nothing on screen reads them, and the flush must see the latest.
85 const queued = useRef<Map<string, boolean>>(new Map());
86 const sentBy = useRef<Map<string, number>>(new Map());
87 const requests = useRef(0);
88 const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
89
90 // Everything held here belongs to one change request on one environment. The environment is
91 // part of that: two of them can hand out the same project id, and a press made against one
92 // must never be answered for by the other.
93 const scopeKey = `${environmentId} ${reference.projectId} ${reference.repository} ${reference.number}`;
94 const scope = useRef(scopeKey);
95
96 // The host's answer as it stood when a completed write was acknowledged, per path. The first
97 // answer that differs from it is the first read that could have seen the write, which is what
98 // retires the press rather than the host happening to agree with it.
99 const answeredFrom = useRef<Map<string, FileViewedStates | null>>(new Map());
100 const statesRef = useRef(states);
101 statesRef.current = states;
102
103 useEffect(() => {
104 const pending = new Set([...queued.current.keys(), ...sentBy.current.keys()]);
105 const answered = new Set<string>();
106 for (const [path, from] of answeredFrom.current) {
107 if (pending.has(path) || from === states) continue;
108 answered.add(path);
109 answeredFrom.current.delete(path);
110 }
111 setOverlay((current) => settleFileViewedOverlay(current, states, pending, answered));
112 }, [states]);
113
114 const flush = useCallback(() => {
115 flushTimer.current = null;
116 const batch = toFileViewedBatch(queued.current);
117 if (batch.length === 0) return;
118 queued.current = new Map();
119 const sentFrom = scope.current;
120 const request = ++requests.current;
121 for (const file of batch) sentBy.current.set(file.path, request);
122 void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => {
123 const mine = batch
124 .map((file) => file.path)
125 .filter((path) => sentBy.current.get(path) === request);
126 for (const path of mine) sentBy.current.delete(path);
127 // The reader has moved on, and what is on screen now has nothing to do with this answer.
128 if (scope.current !== sentFrom) return;
129 if (result._tag === "Failure") {
130 // The host never heard these, so the ticks go back to whatever it last said. Only the
131 // paths this request still answers for: one pressed again since is waiting on a request
132 // of its own, or on the next flush, and that press is the one on screen.
133 const owned = new Set(mine.filter((path) => !queued.current.has(path)));
134 setOverlay((current) => revertFileViewedOverlay(current, batch, owned));
135 // Silent when nothing was still this request's to answer for, so nothing on screen went
136 // back, and when the connection went away mid-flight, which the reader is already being
137 // told about and which the host never refused.
138 if (owned.size > 0 && !isAtomCommandInterrupted(result)) {
139 toastManager.add({ type: "error", title: "Could not update viewed files" });
140 }
141 return;
142 }
143 // Answered for from the next read on, whatever it says. A push landing between the write
144 // and that read comes back as `dismissed`, and the press must not stand over it.
145 for (const path of mine) answeredFrom.current.set(path, statesRef.current);
146 refresh();
147 });
148 }, [environmentId, reference, refresh, setFilesViewed]);
149
150 // Read through a ref rather than closed over: `setViewed` is handed to every file header the
151 // viewer draws, and a new identity per render would rebuild all of them.
152 const flushRef = useRef(flush);
153 flushRef.current = flush;
154
155 // Leaving a change request, the environment it lives on, or the page itself records what was
156 // pressed and then drops the rest. The flush kept here is the one bound to the scope being
157 // left, which is what sends those last presses where they were meant to go.
158 useEffect(() => {
159 const flushScope = flushRef.current;
160 scope.current = scopeKey;
161 return () => {
162 if (flushTimer.current !== null) {
163 clearTimeout(flushTimer.current);
164 flushScope();
165 }
166 queued.current = new Map();
167 sentBy.current = new Map();
168 answeredFrom.current = new Map();
169 setOverlay(NO_OVERLAY);
170 };
171 }, [scopeKey]);
172
173 const refreshRef = useRef(refresh);
174 refreshRef.current = refresh;
175 const refreshFromHost = useCallback(() => refreshRef.current(), []);
176
177 const setViewed = useCallback((path: string, viewed: boolean) => {
178 setOverlay((current) => new Map(current).set(path, viewed));
179 queued.current.set(path, viewed);
180 if (flushTimer.current !== null) clearTimeout(flushTimer.current);
181 flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS);
182 }, []);
183
184 const isViewed = useCallback(
185 (path: string) => isFileViewed(path, states, overlay),
186 [overlay, states],
187 );
188 const isStale = useCallback(
189 (path: string) => !overlay.has(path) && isStaleViewedState(states?.get(path)),
190 [overlay, states],
191 );
192 const viewedCount = useMemo(
193 () => countViewedFiles(paths, states, overlay),
194 [overlay, paths, states],
195 );
196
197 // One identity per change of what it says: the viewer keys every file it draws off this.
198 return useMemo(
199 () => ({
200 enabled,
201 isViewed,
202 isStale,
203 setViewed,
204 viewedCount,
205 truncated,
206 error,
207 refresh: refreshFromHost,
208 }),
209 [enabled, error, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount],
210 );
211}
apps/web/src/lib/diffRendering.test.ts+59
......@@ -209,3 +209,62 @@ describe("getDiffLineStat", () => {
209209 expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 });
210210 });
211211});
212
213describe("a file whose name a patch header cannot carry plainly", () => {
214 /** How git writes such a name, and so how every provider's patch arrives here. */
215 const quotedPatch = (written: string) =>
216 [
217 `diff --git "a/${written}" "b/${written}"`,
218 "index 1111111..2222222 100644",
219 `--- "a/${written}"`,
220 `+++ "b/${written}"`,
221 "@@ -1 +1 @@",
222 "-before",
223 "+after",
224 "",
225 ].join("\n");
226
227 const pathOf = (patch: string) => {
228 const parsed = getRenderablePatch(patch, "review");
229 expect(parsed?.kind).toBe("files");
230 if (parsed?.kind !== "files") throw new Error("patch did not parse as files");
231 const file = parsed.files[0];
232 expect(file).toBeDefined();
233 if (!file) throw new Error("patch carried no file");
234 return resolveFileDiffPath(file);
235 };
236
237 it("is the name the host knows, not the part of it before the tab", () => {
238 // The path is what a viewed mark, a review comment and a file read are all asked for by, so a
239 // name read short is a mark put on a path the host has never heard of.
240 expect(pathOf(quotedPatch("tab\\tfile.txt"))).toBe("tab\tfile.txt");
241 });
242
243 it("is the name the host knows, not the part of it before the newline", () => {
244 expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt");
245 });
246
247 it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => {
248 expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts");
249 });
250
251 it("reads both sides of a rename under the names they really have", () => {
252 const patch = [
253 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
254 "similarity index 90%",
255 'rename from "old\\tname.ts"',
256 'rename to "new\\tname.ts"',
257 "",
258 ].join("\n");
259
260 const parsed = getRenderablePatch(patch, "review");
261 expect(parsed?.kind).toBe("files");
262 if (parsed?.kind !== "files") return;
263 const file = parsed.files[0];
264 expect(file).toBeDefined();
265 if (!file) return;
266
267 expect(resolveFileDiffPath(file)).toBe("new\tname.ts");
268 expect(resolveFileDiffPreviousPath(file)).toBe("old\tname.ts");
269 });
270});
apps/web/src/lib/diffRendering.ts+13-2
......@@ -1,5 +1,6 @@
11import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
22import type { FileDiffMetadata } from "@pierre/diffs/types";
3import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
34
45const DIFF_THEME_NAMES = {
56 light: "pierre-light",
......@@ -143,8 +144,18 @@ export function getRenderablePatch(
143144 }
144145}
145146
147/**
148 * What the patch called the file, as the file's own name. Git writes a name holding a tab, a
149 * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still
150 * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path,
151 * and the host knows the file only under the name it really has.
152 */
153function fileDiffPath(raw: string): string {
154 return unquoteGitPatchPath(raw);
155}
156
146157export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
147 return fileDiff.name ?? fileDiff.prevName ?? "";
158 return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? "");
148159}
149160
150161/**
......@@ -152,7 +163,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
152163 * path, and the hosts that resolve a diff position against both sides need both names.
153164 */
154165export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string {
155 return fileDiff.prevName ?? fileDiff.name ?? "";
166 return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? "");
156167}
157168
158169export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string {
apps/web/src/lib/openPullRequestLink.test.ts+27
......@@ -11,6 +11,7 @@ import {
1111 shouldOpenPullRequestExternally,
1212} from "./openPullRequestLink";
1313import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts";
14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
1415
1516function repositoryIdentity(
1617 provider: string,
......@@ -498,6 +499,32 @@ describe("findProjectForChangeRequest", () => {
498499 ).toBeUndefined();
499500 });
500501
502 it("matches an Azure repository cloned over SSH, whose remote shares no part with its URL", () => {
503 // Azure alone addresses one repository under two names: `ssh.dev.azure.com` and `v3/...` over
504 // SSH against `dev.azure.com` and `.../_git/...` everywhere a person sees it. The identity is
505 // recorded in the spelling a link arrives in, so both halves of this comparison line up.
506 //
507 // Derived from the SSH remote the way the server derives it rather than written out, so the
508 // day that normalization stops reaching the web spelling this fails here too.
509 const canonicalKey = normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code");
510 const projects = [
511 project({
512 canonicalKey,
513 provider: "azure-devops",
514 displayName: canonicalKey.split("/").slice(1).join("/"),
515 owner: "t3tools",
516 name: "t3code",
517 }),
518 ];
519 expect(
520 findProjectForChangeRequest(projects, {
521 host: "dev.azure.com",
522 repository: "t3tools/platform/_git/t3code",
523 number: 1,
524 }),
525 ).toBe(projects[0]);
526 });
527
501528 it("claims nothing for a lookalike host, which is what keeps a link a link", () => {
502529 const projects = [
503530 project({
docs/README.md+1
......@@ -36,6 +36,7 @@ source alone does not explain. Most code changes do not need an internal documen
3636- [Glossary](./internals/glossary.md)
3737- [Connection runtime](./internals/connection-runtime.md)
3838- [Providers](./internals/providers.md)
39- [Pull request file revisions](./internals/pull-request-file-revisions.md)
3940- [Model classification](./internals/model-manifest.md)
4041- [Remote environments](./internals/remote.md)
4142- [Server updates](./internals/server-updates.md)
docs/internals/pull-request-file-revisions.md created+65
......@@ -0,0 +1,65 @@
1# Pull request file revisions
2
3On a host that keeps no viewed-file record of its own, this environment keeps the marks and asks
4the host what version each marked file is at, so that a push can report a cleared file as changed.
5That version travels through four layers, and a missing entry means something different at each
6one. No type distinguishes those meanings and no single file states the whole path, which is what
7this page is for.
8
9The failure is on record rather than hypothetical. The interface doc above
10[GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said
11the opposite of the code beneath it, because it described a missing path the way the layer below
12means it. Anything written against that reading would treat a version the host declined to give as
13a file the change request deleted, which reports every file a reader has cleared as changed across
14a project the token cannot see.
15
16## An absence changes meaning at the provider boundary
17
18Below the boundary, in a patch parser or a response decoder, a missing path means this revision
19does not carry that file. Above it, in
20[`ProviderFileRevisions`](../../apps/server/src/pullRequest/PullRequestProvider.ts), a missing path
21means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its
22host looked at and has no version for arrives as the empty string, and absence is kept for what
23the provider never got to look at. Absence below the boundary does not survive it.
24
25What "never got to look at" is belongs to the host, and the contract cannot know any of them:
26
27- [Azure DevOps](../../apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts) reads every
28 version off one iteration listing, so a change too long to follow to its end leaves the paths
29 past that point out.
30- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the
31 pull request's own patch, the only place it states a file's version, so a patch cut short at the
32 byte ceiling leaves the paths past the cut out.
33- [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch
34 it could not read leaves that batch's paths out.
35
36Those three facts stay with their providers. The contract states only that absence means the read
37could not say, which is the half a provider must convert to.
38
39## Three nulls, and the empty string is not one of them
40
41The empty string is an answer: the host looked, and the head has no version, which is where a
42deleted file sits. It compares equal to a mark stamped with it, so a mark taken against a deletion
43is cleared once and stays cleared. Everything below is the absence of an answer, and the three
44forms are not interchangeable.
45
46A null map, from the service's read of the whole scope, costs those marks their staleness. They
47still report as cleared; they stop noticing pushes. It happens when the provider offers no
48`getFileRevisions` at all, and, on either path, when the call failed and was logged. A press that
49gets no answer does not refuse the reader's tick: it stores the mark with no baseline, which is
50the third form below.
51
52A path absent from an answered map is the per-path case, and it is the one that must not be read
53as a deletion. [`HeldFileRevisions`](../../apps/server/src/pullRequest/pullRequestViewedFiles.ts)
54records what it has asked as well as what it heard, so an absent path keeps the last version given
55for it instead of losing one.
56
57A null on the stored mark is the third, and the only one this environment invents.
58[`PullRequestFileViewedMark`](../../apps/server/src/persistence/PullRequestFilesViewed.ts) stores
59`revision` as nullable so a press that got no answer for a path can store no baseline at all.
60Storing the empty string instead would report the file as changed the moment the host turned out
61to have a version after all. A mark with no baseline reads as cleared until the reader presses it
62again.
63
64Both read-side nulls collapse into that third one on the write path: whether the map was null or
65the path was only absent from it, the press stores a mark with no baseline.
docs/user/source-control.md+16-2
......@@ -118,8 +118,22 @@ for ten minutes during a GitHub outage; new credentials must be verified first.
118118an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or
119119PR creation from Git actions continue to use the project's environment.
120120
121For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support
122reopening a declined pull request.
121For Azure DevOps, use the host website to change comments. Bitbucket does not support reopening a
122declined pull request.
123
124### Mark files as viewed
125
126Tick a file off in the **Code** tab once you have read it and it collapses; the toolbar keeps a
127running count. A tick belongs to the pull request rather than to a commit, so scoping the tab to a
128single commit keeps them. A file pushed to after you cleared it comes back marked **Changed**.
129
130On GitHub these are GitHub's own viewed marks, so a review carries between T3 Code and github.com
131in either direction. Forgejo, GitLab, Bitbucket, and Azure DevOps expose no record T3 Code can read, so the
132server you are connected to keeps them instead: they follow you across the apps connected to that
133server, but the host's own site will not show them, and the count reads **viewed in T3 Code**.
134
135The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but
136does not show its diff, so marks are made and read on web and desktop.
123137
124138## Troubleshooting
125139
packages/client-runtime/src/state/pullRequestRouting.ts+10-3
......@@ -32,10 +32,12 @@ const reads = new Set<string>([
3232 WS_METHODS.pullRequestsActivity,
3333 WS_METHODS.pullRequestsThreadComments,
3434 WS_METHODS.pullRequestsDiffFileContents,
35 WS_METHODS.pullRequestsFilesViewed,
3536 WS_METHODS.pullRequestsReviewerCandidates,
3637 WS_METHODS.pullRequestsLabelCandidates,
3738]);
3839const writes = new Set<string>([
40 WS_METHODS.pullRequestsSetFilesViewed,
3941 WS_METHODS.pullRequestsRunAction,
4042 WS_METHODS.pullRequestsUpdate,
4143 WS_METHODS.pullRequestsComment,
......@@ -182,7 +184,7 @@ export function createPullRequestRouter() {
182184 targets,
183185 ([target, reference]) =>
184186 invalidateTarget(registry, origin.target.environmentId, target, [
185 input.reference === undefined ? {} : { reference },
187 { ...input, ...(input.reference === undefined ? {} : { reference }) },
186188 ]),
187189 { concurrency: 4, discard: true },
188190 );
......@@ -229,8 +231,13 @@ export function createPullRequestRouter() {
229231 targets,
230232 ([target, refs]) =>
231233 invalidateTarget(registry, origin.target.environmentId, target, [
232 ...refs.map((reference) => ({ reference })),
233 {},
234 ...refs.map((reference) => ({
235 reference,
236 ...(tag === WS_METHODS.pullRequestsSetFilesViewed
237 ? { filesViewedOnly: true }
238 : {}),
239 })),
240 ...(tag === WS_METHODS.pullRequestsSetFilesViewed ? [] : [{}]),
234241 ]),
235242 { concurrency: 4, discard: true },
236243 );
packages/client-runtime/src/state/pullRequests.test.ts+78
......@@ -236,6 +236,84 @@ for (const scenario of [
236236 );
237237}
238238
239for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) {
240 it.effect(`routes ${provider} viewed marks to their storage environment`, () =>
241 Effect.scoped(
242 Effect.gen(function* () {
243 const reference = {
244 projectId: ProjectId.make("project-1"),
245 host: "github.com",
246 repository: "acme/web",
247 number: 7,
248 };
249 const calls: { environment: string; operation: string; input: unknown }[] = [];
250 const clientFor = (environment: string) =>
251 ({
252 [WS_METHODS.pullRequestsRouting]: () =>
253 Effect.succeed({
254 host: reference.host,
255 provider,
256 accountId: "123",
257 viewer: "maria-rcks",
258 }),
259 [WS_METHODS.pullRequestsRoutingIdentity]: () =>
260 Effect.succeed({
261 host: reference.host,
262 provider,
263 accountId: "123",
264 viewer: "maria-rcks",
265 }),
266 [WS_METHODS.pullRequestsFilesViewed]: (input: unknown) =>
267 Effect.sync(() => {
268 calls.push({ environment, operation: "read", input });
269 return { files: [{ path: "a.ts", state: "viewed" }], truncated: false };
270 }),
271 [WS_METHODS.pullRequestsSetFilesViewed]: (input: unknown) =>
272 Effect.sync(() => {
273 calls.push({ environment, operation: "write", input });
274 }),
275 [WS_METHODS.pullRequestsInvalidate]: (input: unknown) =>
276 Effect.sync(() => {
277 calls.push({ environment, operation: "invalidate", input });
278 }),
279 }) as unknown as WsRpcProtocolClient;
280 const { environmentRegistry, supervisor } = yield* makeTestRuntime(
281 clientFor("origin"),
282 clientFor("local"),
283 );
284 const files = [{ path: "a.ts", viewed: false }];
285 const route = createPullRequestRouter();
286 yield* Effect.gen(function* () {
287 expect(yield* route(WS_METHODS.pullRequestsFilesViewed, reference)).toEqual({
288 files: [{ path: "a.ts", state: "viewed" }],
289 truncated: false,
290 });
291 yield* route(WS_METHODS.pullRequestsSetFilesViewed, { ...reference, files });
292 }).pipe(
293 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
294 Effect.provideService(GitHubRoutingPermissions, trustedRouting),
295 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
296 );
297 const environment = provider === "github" ? "local" : "origin";
298 const guard = provider === "github" ? { expectedAccountId: "123" } : {};
299 expect(calls.filter((call) => call.operation !== "invalidate")).toEqual([
300 { environment, operation: "read", input: { ...reference, allowStale: false, ...guard } },
301 { environment, operation: "write", input: { ...reference, files, ...guard } },
302 ]);
303 const invalidations = calls.filter((call) => call.operation === "invalidate");
304 if (provider === "github") expect(invalidations.length).toBeGreaterThan(0);
305 else expect(invalidations).toEqual([]);
306 for (const call of invalidations) {
307 expect(call.input).toEqual({
308 reference: expect.objectContaining(reference),
309 filesViewedOnly: true,
310 });
311 }
312 }),
313 ),
314 );
315}
316
239317const TARGET = new PrimaryConnectionTarget({
240318 environmentId: EnvironmentId.make("environment-1"),
241319 label: "Test environment",
packages/client-runtime/src/state/pullRequests.ts+21
......@@ -270,6 +270,27 @@ export function createPullRequestEnvironmentAtoms<R, E>(
270270 ]),
271271 },
272272 }),
273 filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, {
274 label: "environment-data:pull-requests:files-viewed",
275 tag: WS_METHODS.pullRequestsFilesViewed,
276 execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input),
277 staleTimeMs: 15_000,
278 }),
279 /**
280 * One write in flight per change request: the host applies these in order, and a reader
281 * ticking down a file list faster than the round trip would otherwise race their own presses.
282 */
283 setFilesViewed: createEnvironmentRpcCommand(runtime, {
284 label: "environment-data:pull-requests:set-files-viewed",
285 tag: WS_METHODS.pullRequestsSetFilesViewed,
286 execute: (input) => routedRequest(WS_METHODS.pullRequestsSetFilesViewed, input),
287 scheduler: commandScheduler,
288 concurrency: {
289 mode: "serial",
290 key: ({ environmentId, input }) =>
291 JSON.stringify([environmentId, input.projectId, input.repository, input.number]),
292 },
293 }),
273294 runAction: createEnvironmentRpcCommand(runtime, {
274295 label: "environment-data:pull-requests:run-action",
275296 tag: WS_METHODS.pullRequestsRunAction,
packages/contracts/src/pullRequest.test.ts+67
......@@ -4,9 +4,11 @@ import { describe, expect, it } from "vite-plus/test";
44import {
55 PullRequestActionInput,
66 PullRequestCapabilities,
7 PullRequestFilesViewedResult,
78 PullRequestListInput,
89 PullRequestListResult,
910 PullRequestReviewerRequestInput,
11 PullRequestSetFilesViewedInput,
1012 pullRequestHostOf,
1113 resolvePullRequestAuthorFilter,
1214} from "./pullRequest.ts";
......@@ -15,6 +17,8 @@ const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
1517const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
1618const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
1719const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
20const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput);
21const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult);
1822
1923const LIST_RESULT: PullRequestListResult = {
2024 viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
......@@ -271,3 +275,66 @@ describe("naming the reader as the author to narrow by", () => {
271275 expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me");
272276 });
273277});
278
279describe("naming the file a tick belongs to", () => {
280 // A space on either end of a name is part of the name as far as git is concerned. The patch on
281 // screen and the environment's record of what was cleared are both keyed by it, so a path
282 // tidied in transit ticks a file that does not exist and leaves the one on screen unticked.
283 it("keeps the spaces around a path being ticked", () => {
284 expect(
285 decodeSetFilesViewed({
286 projectId: "p1",
287 repository: "group/project",
288 number: 7,
289 files: [{ path: "docs/readme.md ", viewed: true }],
290 }).files,
291 ).toEqual([{ path: "docs/readme.md ", viewed: true }]);
292 });
293
294 it("keeps the spaces around a path being reported back", () => {
295 expect(
296 decodeFilesViewed({
297 files: [{ path: " leading.md", state: "viewed" }],
298 truncated: false,
299 }).files,
300 ).toEqual([{ path: " leading.md", state: "viewed" }]);
301 });
302
303 it("still refuses a path that is nothing at all", () => {
304 expect(() =>
305 decodeSetFilesViewed({
306 projectId: "p1",
307 repository: "group/project",
308 number: 7,
309 files: [{ path: "", viewed: true }],
310 }),
311 ).toThrow();
312 });
313
314 it("refuses a batch larger than a reader can press", () => {
315 // Every element of a batch is a statement of its own inside one transaction on an
316 // environment-kept host, or a field of its own in one GraphQL document on GitHub, so what a
317 // client may send has to be bounded rather than trusted to be a burst of presses.
318 const press = (path: string) => ({ path, viewed: true });
319 const batch = (count: number) => ({
320 projectId: "p1",
321 repository: "group/project",
322 number: 7,
323 files: Array.from({ length: count }, (_, at) => press(`src/f${at}.ts`)),
324 });
325
326 expect(() => decodeSetFilesViewed(batch(500))).not.toThrow();
327 expect(() => decodeSetFilesViewed(batch(501))).toThrow();
328 });
329
330 it("refuses a path far longer than any real one", () => {
331 expect(() =>
332 decodeSetFilesViewed({
333 projectId: "p1",
334 repository: "group/project",
335 number: 7,
336 files: [{ path: `src/${"a".repeat(4096)}.ts`, viewed: true }],
337 }),
338 ).toThrow();
339 });
340});
packages/contracts/src/pullRequest.ts+75
......@@ -371,6 +371,14 @@ export const PullRequestReviewerCapabilities = Schema.Struct({
371371});
372372export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type;
373373
374/**
375 * Who remembers which files a reader has cleared. `host` is the host's own record, so the marks
376 * are the ones its web UI shows and a review can be carried on from either side. `environment` is
377 * this server's record, for a host that keeps none anything outside one browser can read.
378 */
379export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]);
380export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type;
381
374382/**
375383 * What a provider can actually do, so a surface can hide what is missing rather than offer an
376384 * action that would fail. Every provider fills this in for itself; nothing is assumed.
......@@ -407,6 +415,11 @@ export const PullRequestCapabilities = Schema.Struct({
407415 * what every server before this field was.
408416 */
409417 reactions: Schema.optional(Schema.Boolean),
418 /**
419 * Where the reader's own marks are kept, or absent where they are kept nowhere and the
420 * checkbox is not offered at all. Optional for the same reason as `reactions`.
421 */
422 viewedFiles: Schema.optional(PullRequestViewedFilesStore),
410423 review: PullRequestReviewCapabilities,
411424 reviewers: PullRequestReviewerCapabilities,
412425 /**
......@@ -784,6 +797,8 @@ export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type;
784797 */
785798export const PullRequestInvalidateInput = Schema.Struct({
786799 reference: Schema.optional(PullRequestRef),
800 /** Refresh review progress across routed environments without discarding the patch. */
801 filesViewedOnly: Schema.optional(Schema.Boolean),
787802});
788803export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type;
789804
......@@ -953,6 +968,66 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({
953968});
954969export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type;
955970
971/**
972 * Bounded because a path arrives from a client rather than from the host: one element of a write
973 * batch could otherwise carry a megabyte into a SQL statement or a GraphQL field.
974 */
975const MAX_FILE_PATH_LENGTH = 4096;
976/**
977 * Not trimmed: a leading or trailing space is a legal part of a file's name, and the mark is
978 * keyed by the name the host gave, so trimming files it under a name nothing else uses.
979 */
980const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH));
981
982/**
983 * Where one file of a change request stands with the person reading it. `dismissed` is the file
984 * that was cleared and has since been pushed to, which is worth telling a reader apart from
985 * `unviewed`: this file and not the other forty is the one that moved.
986 */
987export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]);
988export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type;
989
990export const PullRequestFileViewed = Schema.Struct({
991 path: FilePath,
992 state: PullRequestFileViewedState,
993});
994export type PullRequestFileViewed = typeof PullRequestFileViewed.Type;
995
996/**
997 * Which files of a change request the reader has cleared. Its own read rather than a field on the
998 * patch: a patch changes when somebody pushes and is cached by the minute, this changes on every
999 * press, so one read would have to be wrong for the other to be right.
1000 */
1001export const PullRequestFilesViewedResult = Schema.Struct({
1002 /** Only the files the host reported a state for. A file missing from this list is unviewed. */
1003 files: Schema.Array(PullRequestFileViewed),
1004 /** The host had more files than were read, so the count is short and says so. */
1005 truncated: Schema.Boolean,
1006});
1007export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type;
1008
1009/**
1010 * How many presses one write carries. Every element is a statement of its own inside one
1011 * transaction here, or a field of its own in one GraphQL document on GitHub. Matched to what a
1012 * read of the marks carries, so a client cannot write more of them than it can ever read back.
1013 */
1014const MAX_FILES_VIEWED_PRESSES = 500;
1015
1016/**
1017 * Files to clear, or to put back. Several at once because a reader working down a diff ticks
1018 * boxes far faster than a host answers, so a burst is gathered into one request.
1019 */
1020export const PullRequestSetFilesViewedInput = Schema.Struct({
1021 ...PullRequestRef.fields,
1022 files: Schema.Array(
1023 Schema.Struct({
1024 path: FilePath,
1025 viewed: Schema.Boolean,
1026 }),
1027 ).check(Schema.isMaxLength(MAX_FILES_VIEWED_PRESSES)),
1028});
1029export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type;
1030
9561031export const PullRequestStackHead = Schema.Struct({
9571032 number: PositiveInt,
9581033 headSha: TrimmedNonEmptyString,
packages/contracts/src/rpc.ts+18
......@@ -113,6 +113,7 @@ import {
113113 PullRequestDetail,
114114 PullRequestDiffFileContentsInput,
115115 PullRequestDiffFileContentsResult,
116 PullRequestFilesViewedResult,
116117 PullRequestInvalidateInput,
117118 PullRequestListInput,
118119 PullRequestListResult,
......@@ -131,6 +132,7 @@ import {
131132 PullRequestReviewerRequestInput,
132133 PullRequestLabelCandidateList,
133134 PullRequestLabelChangeInput,
135 PullRequestSetFilesViewedInput,
134136 PullRequestSubmitReviewInput,
135137 PullRequestThreadCommentsInput,
136138 PullRequestThreadCommentsResult,
......@@ -396,6 +398,8 @@ export const WS_METHODS = {
396398 pullRequestsActivity: "pullRequests.activity",
397399 pullRequestsThreadComments: "pullRequests.threadComments",
398400 pullRequestsDiffFileContents: "pullRequests.diffFileContents",
401 pullRequestsFilesViewed: "pullRequests.filesViewed",
402 pullRequestsSetFilesViewed: "pullRequests.setFilesViewed",
399403 pullRequestsRunAction: "pullRequests.runAction",
400404 pullRequestsUpdate: "pullRequests.update",
401405 pullRequestsComment: "pullRequests.comment",
......@@ -751,6 +755,18 @@ const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFi
751755 error: PullRequestRpcError,
752756});
753757
758const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, {
759 payload: PullRequestRef,
760 success: PullRequestFilesViewedResult,
761 error: PullRequestRpcError,
762});
763
764const WsPullRequestsSetFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsSetFilesViewed, {
765 payload: PullRequestSetFilesViewedInput,
766 success: Schema.Void,
767 error: PullRequestRpcError,
768});
769
754770const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, {
755771 payload: PullRequestActionInput,
756772 success: Schema.Void,
......@@ -1405,6 +1421,8 @@ export const WsRpcGroup = RpcGroup.make(
14051421 WsPullRequestsActivityRpc,
14061422 WsPullRequestsThreadCommentsRpc,
14071423 WsPullRequestsDiffFileContentsRpc,
1424 WsPullRequestsFilesViewedRpc,
1425 WsPullRequestsSetFilesViewedRpc,
14081426 WsPullRequestsRunActionRpc,
14091427 WsPullRequestsUpdateRpc,
14101428 WsPullRequestsCommentRpc,
packages/shared/package.json+4
......@@ -338,6 +338,10 @@
338338 "./cliRelease": {
339339 "types": "./src/cliRelease.ts",
340340 "import": "./src/cliRelease.ts"
341 },
342 "./gitPatchPath": {
343 "types": "./src/gitPatchPath.ts",
344 "import": "./src/gitPatchPath.ts"
341345 }
342346 },
343347 "scripts": {
packages/shared/src/git.test.ts+32
......@@ -50,6 +50,38 @@ describe("normalizeGitRemoteUrl", () => {
5050 "bitbucket.org/workspace/repo",
5151 );
5252 });
53
54 it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => {
55 expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe(
56 "dev.azure.com/t3tools/platform/_git/t3code",
57 );
58 expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe(
59 "dev.azure.com/t3tools/platform/_git/t3code",
60 );
61 expect(
62 normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"),
63 ).toBe("dev.azure.com/t3tools/platform/_git/t3code");
64 });
65
66 it("puts the organization back in the host on the name dev.azure.com replaced", () => {
67 expect(
68 normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"),
69 ).toBe("t3tools.visualstudio.com/platform/_git/t3code");
70 expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe(
71 "t3tools.visualstudio.com/platform/_git/t3code",
72 );
73 });
74
75 it("leaves an Azure SSH host it cannot read as the path it was given", () => {
76 // Not `v3`, and not four segments: rewriting either would invent a repository that the web
77 // spelling has no name for, so the remote stands as it arrived.
78 expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v4/T3Tools/Platform/T3Code")).toBe(
79 "ssh.dev.azure.com/v4/t3tools/platform/t3code",
80 );
81 expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/T3Code")).toBe(
82 "ssh.dev.azure.com/v3/t3tools/t3code",
83 );
84 });
5385});
5486
5587describe("parseOriginUrlFromGitConfig", () => {
packages/shared/src/git.ts+29-8
......@@ -108,6 +108,25 @@ export function isTemporaryWorktreeBranch(refName: string): boolean {
108108 return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
109109}
110110
111/**
112 * The web spelling of an Azure DevOps repository reached over SSH, or null for anything else.
113 *
114 * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and
115 * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}`
116 * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository
117 * to every comparison made against a pull request URL, which arrives in the web spelling. So the
118 * web spelling is the one both are keyed by.
119 */
120function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray<string>): string | null {
121 if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null;
122 const [marker, organization, project, repository] = segments;
123 if (segments.length !== 4 || marker !== "v3") return null;
124 if (!organization || !project || !repository) return null;
125 return host === "ssh.dev.azure.com"
126 ? `dev.azure.com/${organization}/${project}/_git/${repository}`
127 : `${organization}.visualstudio.com/${project}/_git/${repository}`;
128}
129
111130/**
112131 * Normalize a git remote URL into a stable comparison key.
113132 */
......@@ -121,12 +140,12 @@ export function normalizeGitRemoteUrl(value: string): string {
121140 if (/^(?:ssh|https?|git):\/\//i.test(normalized)) {
122141 try {
123142 const url = new URL(normalized);
124 const repositoryPath = url.pathname
125 .split("/")
126 .filter((segment) => segment.length > 0)
127 .join("/");
128 if (url.hostname && repositoryPath.includes("/")) {
129 return `${url.hostname}/${repositoryPath}`;
143 const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0);
144 if (url.hostname && repositorySegments.length > 1) {
145 return (
146 azureDevOpsRepositoryKey(url.hostname, repositorySegments) ??
147 `${url.hostname}/${repositorySegments.join("/")}`
148 );
130149 }
131150 } catch {
132151 return normalized;
......@@ -136,8 +155,10 @@ export function normalizeGitRemoteUrl(value: string): string {
136155 const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec(
137156 normalized,
138157 );
139 if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {
140 return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;
158 const scpHost = scpStyleHostAndPath?.[1];
159 const scpPath = scpStyleHostAndPath?.[2];
160 if (scpHost && scpPath) {
161 return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`;
141162 }
142163
143164 return normalized;
packages/shared/src/gitPatchPath.test.ts created+97
......@@ -0,0 +1,97 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { quoteGitPatchPath, unquoteGitPatchPath } from "./gitPatchPath.ts";
4
5const BELL = "\u0007";
6const UNIT_SEPARATOR = "\u001f";
7const DELETE = "\u007f";
8
9describe("quoteGitPatchPath", () => {
10 it("leaves a name a header can carry as itself", () => {
11 expect(quoteGitPatchPath("src/app.ts")).toBe("src/app.ts");
12 expect(quoteGitPatchPath("with space.txt")).toBe("with space.txt");
13 // Outside ASCII is only quoted for what a terminal can show, and a diff viewer is not one.
14 expect(quoteGitPatchPath("café/résumé.ts")).toBe("café/résumé.ts");
15 expect(quoteGitPatchPath("🚀.ts")).toBe("🚀.ts");
16 });
17
18 it("writes git's escape for a character the header would read as its own", () => {
19 expect(quoteGitPatchPath("tab\tfile.txt")).toBe('"tab\\tfile.txt"');
20 expect(quoteGitPatchPath("line\nfile.txt")).toBe('"line\\nfile.txt"');
21 expect(quoteGitPatchPath('quo"te.txt')).toBe('"quo\\"te.txt"');
22 expect(quoteGitPatchPath("back\\slash.txt")).toBe('"back\\\\slash.txt"');
23 expect(quoteGitPatchPath(`ring${BELL}.txt`)).toBe('"ring\\a.txt"');
24 });
25
26 it("writes the octal of a control character git has no name for", () => {
27 expect(quoteGitPatchPath(`unit${UNIT_SEPARATOR}.txt`)).toBe('"unit\\037.txt"');
28 expect(quoteGitPatchPath(`del${DELETE}.txt`)).toBe('"del\\177.txt"');
29 });
30});
31
32describe("a name written into a header and read back out", () => {
33 const names = [
34 "src/app.ts",
35 "with space.txt",
36 "café/résumé.ts",
37 "🚀.ts",
38 "tab\tfile.txt",
39 "line\nfile.txt",
40 "carriage\rfile.txt",
41 'quo"te.txt',
42 "back\\slash.txt",
43 `ring${BELL}.txt`,
44 `unit${UNIT_SEPARATOR}.txt`,
45 `del${DELETE}.txt`,
46 `every\t\n\r"\\${BELL}.txt`,
47 ];
48
49 for (const name of names) {
50 it(`is the name that went in: ${JSON.stringify(name)}`, () => {
51 const written = quoteGitPatchPath(name);
52 expect(unquoteGitPatchPath(written)).toBe(name);
53 // A parser that takes the quotes off itself, as the clients' one does, gets there too.
54 const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written;
55 expect(unquoteGitPatchPath(unwrapped)).toBe(name);
56 });
57 }
58
59 it("carries the whole name past the first thing a header stops at", () => {
60 const written = quoteGitPatchPath("tab\tfile.txt");
61
62 // The bug this guards: a header line ends at a tab and splits on a space, so the name written
63 // as itself would be read as `tab` and marked viewed under a path no host has.
64 expect(written).not.toContain("\t");
65 expect(written).not.toContain("\n");
66 expect(unquoteGitPatchPath(written)).not.toBe("tab");
67 });
68});
69
70describe("unquoteGitPatchPath", () => {
71 it("leaves a token git saw no need to quote alone", () => {
72 expect(unquoteGitPatchPath("src/app.ts")).toBe("src/app.ts");
73 expect(unquoteGitPatchPath("with space.txt")).toBe("with space.txt");
74 expect(unquoteGitPatchPath("")).toBe("");
75 expect(unquoteGitPatchPath('"')).toBe('"');
76 });
77
78 it("reads the octal escapes a host with core.quotePath on writes", () => {
79 expect(unquoteGitPatchPath('"caf\\303\\251/r\\303\\251sum\\303\\251.ts"')).toBe(
80 "café/résumé.ts",
81 );
82 expect(unquoteGitPatchPath('"\\360\\237\\232\\200.ts"')).toBe("🚀.ts");
83 });
84});
85
86describe("a name a parser handed back with its quotes already off", () => {
87 it("is read for the escapes it still carries", () => {
88 expect(unquoteGitPatchPath("tab\\tfile.txt")).toBe("tab\tfile.txt");
89 expect(unquoteGitPatchPath("caf\\303\\251.ts")).toBe("café.ts");
90 });
91
92 it("reads an escape git would never write the way C reads it", () => {
93 expect(unquoteGitPatchPath("odd\\zname.txt")).toBe("oddzname.txt");
94 expect(unquoteGitPatchPath("trailing\\")).toBe("trailing\\");
95 expect(unquoteGitPatchPath("short\\12.txt")).toBe("short12.txt");
96 });
97});
packages/shared/src/gitPatchPath.ts created+140
......@@ -0,0 +1,140 @@
1/**
2 * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in
3 * a header by where it stops: `diff --git a/<old> b/<new>` splits on a space, `--- a/<name>` stops
4 * at a tab. So a name holding a tab or a newline reads back as the part of itself before that
5 * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted
6 * with C-style escapes. Both halves live here so what one writes is what the other reads.
7 */
8
9/**
10 * What git escapes by name, as the character written and the escape written for it. Not every byte
11 * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for
12 * what a terminal can show rather than anything the format needs, and a patch here is read by a
13 * diff viewer.
14 */
15const ESCAPE_BY_CHARACTER = new Map([
16 ['"', '\\"'],
17 ["\\", "\\\\"],
18 ["\u0007", "\\a"],
19 ["\b", "\\b"],
20 ["\t", "\\t"],
21 ["\n", "\\n"],
22 ["\v", "\\v"],
23 ["\f", "\\f"],
24 ["\r", "\\r"],
25]);
26
27/** The escape written for a character, as the byte it stands for. */
28const CHARACTER_BY_ESCAPE: Record<string, number> = {
29 '"': 0x22,
30 "\\": 0x5c,
31 a: 0x07,
32 b: 0x08,
33 f: 0x0c,
34 n: 0x0a,
35 r: 0x0d,
36 t: 0x09,
37 v: 0x0b,
38};
39
40const QUOTE = '"';
41const DELETE_CHARACTER = 0x7f;
42const LOWEST_PRINTABLE = 0x20;
43const BACKSLASH = 0x5c;
44
45const utf8 = new TextEncoder();
46const fromUtf8 = new TextDecoder();
47
48/**
49 * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form
50 * where it is not, which is any name holding a quote, a backslash or a control character. A header
51 * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git
52 * quotes is the whole token a reader takes off the line, side letter and all.
53 */
54export function quoteGitPatchPath(path: string): string {
55 let body = "";
56 let quoting = false;
57 for (const character of path) {
58 const escape = ESCAPE_BY_CHARACTER.get(character);
59 if (escape !== undefined) {
60 body += escape;
61 quoting = true;
62 continue;
63 }
64 const code = character.codePointAt(0) ?? 0;
65 // Every character git has no name for is written as the octal of its byte, and a control
66 // character is one byte in UTF-8, so the character's own code point is that byte.
67 if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) {
68 body += `\\${code.toString(8).padStart(3, "0")}`;
69 quoting = true;
70 continue;
71 }
72 body += character;
73 }
74 return quoting ? `${QUOTE}${body}${QUOTE}` : path;
75}
76
77/**
78 * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name
79 * holding no backslash is already itself, and an escape git would never write reads the way C
80 * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and
81 * only reads back as itself once those bytes are rejoined and decoded together.
82 */
83function unescapeBody(body: string): string {
84 if (!body.includes("\\")) return body;
85 const bytes: Array<number> = [];
86 // Anything left as itself is encoded a run at a time rather than a unit at a time, so a
87 // character outside the basic plane keeps its pair together rather than coming back as halves.
88 let literal = "";
89 const flush = () => {
90 if (literal.length === 0) return;
91 bytes.push(...utf8.encode(literal));
92 literal = "";
93 };
94 let at = 0;
95 while (at < body.length) {
96 const character = body.charAt(at);
97 if (character !== "\\") {
98 literal += character;
99 at += 1;
100 continue;
101 }
102 const escaped = body.charAt(at + 1);
103 if (escaped === "") {
104 flush();
105 bytes.push(BACKSLASH);
106 break;
107 }
108 const named = CHARACTER_BY_ESCAPE[escaped];
109 if (named !== undefined) {
110 flush();
111 bytes.push(named);
112 at += 2;
113 continue;
114 }
115 const octal = body.slice(at + 1, at + 4);
116 if (/^[0-7]{3}$/.test(octal)) {
117 flush();
118 bytes.push(Number.parseInt(octal, 8));
119 at += 4;
120 continue;
121 }
122 literal += escaped;
123 at += 2;
124 }
125 flush();
126 return fromUtf8.decode(new Uint8Array(bytes));
127}
128
129/**
130 * One header's name token as the name it stands for. The escapes are undone whether the quotes are
131 * still there or not, because patch parsers disagree about how much of the quoting they hand back:
132 * the one the clients read diffs with takes the quotes off the `diff --git` line's names and
133 * leaves them on a rename's.
134 */
135export function unquoteGitPatchPath(token: string): string {
136 if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) {
137 return unescapeBody(token.slice(1, -1));
138 }
139 return unescapeBody(token);
140}
packages/shared/src/sourceControl.test.ts+14
......@@ -208,3 +208,17 @@ it("keeps a GitLab identity's whole path, because a nested group is part of the
208208 });
209209 expect(selector).toBe("group/subgroup/service");
210210});
211
212it("puts owner and name back together for an identity recorded before displayName", () => {
213 const selector = sourceControlRepositorySelector({
214 provider: "github",
215 owner: "t3tools",
216 name: "t3code",
217 });
218 expect(selector).toBe("t3tools/t3code");
219});
220
221it("names nothing for a project with no remote to name it by", () => {
222 expect(sourceControlRepositorySelector(null)).toBeNull();
223 expect(sourceControlRepositorySelector({ provider: "github" })).toBeNull();
224});
pnpm-lock.yaml+3
......@@ -512,6 +512,9 @@ importers:
512512 '@opencode-ai/sdk':
513513 specifier: ^1.3.15
514514 version: 1.15.13
515 diff:
516 specifier: 8.0.3
517 version: 8.0.3
515518 effect:
516519 specifier: 4.0.0-rc.112
517520 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)