| author | |
| committer | |
| log | 03950089ffa5cecf0ce731227f58551f6437495f |
| tree | 929c9d3e04a6bd6117ecf92d14f66fc1b09f7aba |
| parent | 8db3c250f41e8f1d0b36da6b5a5093821e72d4f3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | 2 | import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; |
| 3 | 3 | import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; |
| 4 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 4 | 5 | import * as Arr from "effect/Array"; |
| 5 | 6 | import { pipe } from "effect/Function"; |
| 6 | 7 | import * as Order from "effect/Order"; |
| ... | ... | @@ -372,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender |
| 372 | 373 | } |
| 373 | 374 | |
| 374 | 375 | function 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; | |
| 377 | 378 | const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0); |
| 378 | 379 | const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); |
| 379 | 380 | const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; |
apps/server/package.json+1| ... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 | "@effect/platform-node-shared": "catalog:", |
| 29 | 29 | "@ff-labs/fff-node": "0.9.4", |
| 30 | 30 | "@opencode-ai/sdk": "^1.3.15", |
| 31 | "diff": "8.0.3", | |
| 31 | 32 | "effect": "catalog:", |
| 32 | 33 | "msgpackr-extract": "3.0.4", |
| 33 | 34 | "node-pty": "^1.1.0", |
apps/server/src/auth/RpcAuthorization.ts+2| ... | ... | @@ -76,6 +76,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 76 | 76 | [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, |
| 77 | 77 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
| 78 | 78 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, |
| 79 | [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, | |
| 79 | 80 | [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, |
| 80 | 81 | [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, |
| 81 | 82 | [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, |
| ... | ... | @@ -84,6 +85,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 84 | 85 | [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, |
| 85 | 86 | [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, |
| 86 | 87 | [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, |
| 88 | [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope, | |
| 87 | 89 | // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only |
| 88 | 90 | // client pressing refresh must not be told it may not look again. |
| 89 | 91 | [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, |
apps/server/src/persistence/Errors.ts+1| ... | ... | @@ -167,5 +167,6 @@ export type OrchestrationCommandReceiptRepositoryError = |
| 167 | 167 | export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
| 168 | 168 | export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
| 169 | 169 | export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
| 170 | export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError; | |
| 170 | 171 | |
| 171 | 172 | export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -64,6 +64,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; |
| 64 | 64 | import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; |
| 65 | 65 | import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; |
| 66 | 66 | import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; |
| 67 | import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; | |
| 67 | 68 | |
| 68 | 69 | /** |
| 69 | 70 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -128,6 +129,7 @@ const migrationEntries = [ |
| 128 | 129 | [50, "ProjectionThreadPullRequests", Migration0050], |
| 129 | 130 | [51, "ProjectionThreadMessageContext", Migration0051], |
| 130 | 131 | [52, "ProjectionThreadTitleState", Migration0052], |
| 132 | [53, "PullRequestFilesViewed", Migration0053], | |
| 131 | 133 | ] as const; |
| 132 | 134 | |
| 133 | 135 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/053_PullRequestFilesViewed.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 7 | ||
| 8 | import { SourceControlProviderKind } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | import { | |
| 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 | */ | |
| 22 | export const PullRequestFilesViewedScope = Schema.Struct({ | |
| 23 | provider: SourceControlProviderKind, | |
| 24 | host: Schema.String, | |
| 25 | repository: Schema.String, | |
| 26 | number: Schema.Int, | |
| 27 | viewer: Schema.String, | |
| 28 | }); | |
| 29 | export type PullRequestFilesViewedScope = typeof PullRequestFilesViewedScope.Type; | |
| 30 | ||
| 31 | /** A file this reader cleared, and what it was when they cleared it. */ | |
| 32 | export 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 | }); | |
| 45 | export type PullRequestFileViewedMark = typeof PullRequestFileViewedMark.Type; | |
| 46 | ||
| 47 | export 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 | */ | |
| 59 | export const MAX_FILES_VIEWED_ROWS = 500; | |
| 60 | ||
| 61 | /** The marks for one scope, and whether the store had more of them than it carried. */ | |
| 62 | export 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 | */ | |
| 73 | export 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 | ||
| 85 | function 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 | ||
| 92 | const 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 | ||
| 184 | export const layer = Layer.effect(PullRequestFilesViewedRepository, make); |
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+836-18| ... | ... | @@ -29,6 +29,45 @@ function output(stdout: string) { |
| 29 | 29 | }; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | /** What VcsProcess allows a read that asked for no ceiling of its own. */ | |
| 33 | const 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 | */ | |
| 40 | function 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. */ | |
| 48 | const json = (value: Record<string, unknown>) => JSON.stringify(value); | |
| 49 | ||
| 50 | const 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 | ||
| 61 | const oneIteration = { | |
| 62 | value: [ | |
| 63 | { | |
| 64 | id: 1, | |
| 65 | sourceRefCommit: { commitId: "a".repeat(40) }, | |
| 66 | commonRefCommit: { commitId: "b".repeat(40) }, | |
| 67 | }, | |
| 68 | ], | |
| 69 | }; | |
| 70 | ||
| 32 | 71 | function pullRequestRows( |
| 33 | 72 | count: number, |
| 34 | 73 | firstNumber: number, |
| ... | ... | @@ -56,6 +95,92 @@ function argsOfCall(index: number): ReadonlyArray<string> { |
| 56 | 95 | return call[0].args; |
| 57 | 96 | } |
| 58 | 97 | |
| 98 | /** The output ceiling the nth az invocation asked for, if it asked for one at all. */ | |
| 99 | function 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. */ | |
| 106 | function 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. */ | |
| 125 | function 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. */ | |
| 143 | function 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. */ | |
| 168 | function 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 | ||
| 59 | 184 | afterEach(() => { |
| 60 | 185 | mockedExecute.mockReset(); |
| 61 | 186 | }); |
| ... | ... | @@ -107,20 +232,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { |
| 107 | 232 | const response = JSON.stringify(rows); |
| 108 | 233 | expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); |
| 109 | 234 | |
| 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 | ); | |
| 124 | 238 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; |
| 125 | 239 | |
| 126 | 240 | const batch = yield* cli.listPullRequests({ |
| ... | ... | @@ -516,6 +630,590 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { |
| 516 | 630 | }), |
| 517 | 631 | ); |
| 518 | 632 | |
| 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 | ||
| 519 | 1217 | it.effect("reads the conversation through the REST API, pinned to a version", () => |
| 520 | 1218 | Effect.gen(function* () { |
| 521 | 1219 | mockedExecute.mockReturnValueOnce( |
| ... | ... | @@ -539,14 +1237,134 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { |
| 539 | 1237 | |
| 540 | 1238 | const comments = yield* cli.listThreads({ |
| 541 | 1239 | 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, | |
| 543 | 1242 | }); |
| 544 | 1243 | |
| 545 | 1244 | 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)), | |
| 549 | 1266 | ); |
| 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"); | |
| 550 | 1368 | }), |
| 551 | 1369 | ); |
| 552 | 1370 |
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+222-23| ... | ... | @@ -13,11 +13,18 @@ import type { |
| 13 | 13 | |
| 14 | 14 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 15 | 15 | import { |
| 16 | decodeItemContentJson, | |
| 17 | decodeIterationChangesJson, | |
| 18 | decodeIterationsJson, | |
| 16 | 19 | decodePullRequestJson, |
| 17 | 20 | decodePullRequestListJson, |
| 18 | 21 | decodeThreadsJson, |
| 19 | 22 | decodeViewerJson, |
| 23 | type AzureDevOpsChangeEntry, | |
| 24 | type AzureDevOpsItemContent, | |
| 25 | type AzureDevOpsIteration, | |
| 20 | 26 | type AzureDevOpsPullRequest, |
| 27 | type AzureDevOpsRepositoryLocation, | |
| 21 | 28 | } from "./azureDevOpsPullRequestJson.ts"; |
| 22 | 29 | import type { ProviderListCursor } from "./PullRequestProvider.ts"; |
| 23 | 30 | |
| ... | ... | @@ -112,6 +119,44 @@ export type AzureDevOpsPullRequestCliError = |
| 112 | 119 | /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ |
| 113 | 120 | const REST_API_VERSION = "7.1"; |
| 114 | 121 | const 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 | */ | |
| 128 | const 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 | */ | |
| 134 | const 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 | */ | |
| 141 | const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | |
| 142 | ||
| 143 | /** Azure's own ceiling for one page of an iteration's changes. */ | |
| 144 | const 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 | */ | |
| 153 | const MAX_CHANGE_ENTRIES = 10_000; | |
| 154 | ||
| 155 | /** What an iteration changed, and whether following its pages reached the end of it. */ | |
| 156 | export interface AzureDevOpsIterationChanges { | |
| 157 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; | |
| 158 | readonly truncated: boolean; | |
| 159 | } | |
| 115 | 160 | |
| 116 | 161 | export class AzureDevOpsPullRequestCli extends Context.Service< |
| 117 | 162 | AzureDevOpsPullRequestCli, |
| ... | ... | @@ -150,9 +195,42 @@ export class AzureDevOpsPullRequestCli extends Context.Service< |
| 150 | 195 | /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ |
| 151 | 196 | readonly listThreads: (input: { |
| 152 | 197 | readonly cwd: string; |
| 153 | readonly threadsUrl: string; | |
| 198 | readonly location: AzureDevOpsRepositoryLocation; | |
| 199 | readonly number: number; | |
| 154 | 200 | }) => Effect.Effect<ReadonlyArray<PullRequestComment>, AzureDevOpsPullRequestCliError>; |
| 155 | 201 | |
| 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 | ||
| 156 | 234 | readonly runPullRequestAction: (input: { |
| 157 | 235 | readonly cwd: string; |
| 158 | 236 | readonly number: number; |
| ... | ... | @@ -281,6 +359,77 @@ export const make = Effect.gen(function* () { |
| 281 | 359 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), |
| 282 | 360 | }); |
| 283 | 361 | |
| 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 | ||
| 284 | 433 | /** |
| 285 | 434 | * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and |
| 286 | 435 | * 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* () { |
| 448 | 597 | ), |
| 449 | 598 | |
| 450 | 599 | listThreads: (input) => |
| 451 | executeJson({ | |
| 600 | invoke({ | |
| 452 | 601 | 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", | |
| 459 | 670 | ], |
| 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 | }), | |
| 475 | 674 | |
| 476 | 675 | setPullRequestReviewers: (input) => |
| 477 | 676 | input.reviewers.some((reviewer) => !isReviewerName(reviewer)) |
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts created+420| ... | ... | @@ -0,0 +1,420 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | ||
| 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | |
| 6 | import { | |
| 7 | LOCATION_CACHE_CAPACITY, | |
| 8 | make, | |
| 9 | MAX_DIFF_SPAWNS, | |
| 10 | } from "./AzureDevOpsPullRequestProvider.ts"; | |
| 11 | import { | |
| 12 | byteLength, | |
| 13 | MAX_DIFF_SLICE_BYTES, | |
| 14 | MAX_DIFF_SLICE_FILES, | |
| 15 | MAX_FILE_DIFF_EDITS, | |
| 16 | parseAzureDevOpsDiffCursor, | |
| 17 | } from "./azureDevOpsDiff.ts"; | |
| 18 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 19 | ||
| 20 | const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; | |
| 21 | ||
| 22 | const 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 | ||
| 42 | function 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 | */ | |
| 55 | function 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 | ||
| 60 | const 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. */ | |
| 131 | function 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 | ||
| 137 | describe("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 | ||
| 171 | describe("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 | ||
| 197 | describe("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 | ||
| 308 | describe("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 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Semaphore from "effect/Semaphore"; | |
| 2 | 3 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; |
| 3 | 4 | |
| 4 | 5 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; |
| 6 | import { | |
| 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"; | |
| 5 | 18 | import { |
| 6 | 19 | PullRequestProviderError, |
| 7 | 20 | type PullRequestProviderFailure, |
| 8 | 21 | type ProviderChangeRequest, |
| 9 | 22 | type ProviderChangeRequestActivity, |
| 10 | 23 | type ProviderChangeRequestDetail, |
| 24 | type ProviderChangeRequestSummary, | |
| 25 | type ProviderDiffSlice, | |
| 11 | 26 | type PullRequestProviderApi, |
| 12 | 27 | } from "./PullRequestProvider.ts"; |
| 13 | import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; | |
| 28 | import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts"; | |
| 29 | import 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 | */ | |
| 43 | const 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 | */ | |
| 51 | export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; | |
| 52 | ||
| 53 | /** How many pull requests' repository locations one provider remembers at once. */ | |
| 54 | export const LOCATION_CACHE_CAPACITY = 128; | |
| 14 | 55 | |
| 15 | 56 | const 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, | |
| 19 | 60 | // Reading a conversation is a plain REST read, but posting one is not something this can |
| 20 | 61 | // claim without having run it, so the composer stays hidden. |
| 21 | 62 | comment: false, |
| ... | ... | @@ -33,7 +74,8 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 33 | 74 | // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. |
| 34 | 75 | search: false, |
| 35 | 76 | 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. | |
| 37 | 79 | review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, |
| 38 | 80 | // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` |
| 39 | 81 | // lists the ones this repository could name — that lives behind the identity and graph APIs, a |
| ... | ... | @@ -44,6 +86,11 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 44 | 86 | // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on |
| 45 | 87 | // Azure DevOps at all, so there is nothing here it could rewrite either. |
| 46 | 88 | 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", | |
| 47 | 94 | }; |
| 48 | 95 | |
| 49 | 96 | /** |
| ... | ... | @@ -85,8 +132,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq |
| 85 | 132 | state: pullRequest.state, |
| 86 | 133 | isDraft: pullRequest.isDraft, |
| 87 | 134 | 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. | |
| 90 | 137 | additions: 0, |
| 91 | 138 | deletions: 0, |
| 92 | 139 | createdAt: pullRequest.createdAt, |
| ... | ... | @@ -101,6 +148,11 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq |
| 101 | 148 | |
| 102 | 149 | export const make = Effect.gen(function* () { |
| 103 | 150 | 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)); | |
| 104 | 156 | |
| 105 | 157 | const fail = |
| 106 | 158 | (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => |
| ... | ... | @@ -123,6 +175,123 @@ export const make = Effect.gen(function* () { |
| 123 | 175 | }), |
| 124 | 176 | ); |
| 125 | 177 | |
| 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 | ||
| 126 | 295 | const provider: PullRequestProviderApi = { |
| 127 | 296 | kind: "azure-devops", |
| 128 | 297 | capabilities: CAPABILITIES, |
| ... | ... | @@ -156,13 +325,52 @@ export const make = Effect.gen(function* () { |
| 156 | 325 | })), |
| 157 | 326 | ), |
| 158 | 327 | |
| 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) => | |
| 160 | 331 | 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 = { | |
| 163 | 371 | ...toChangeRequest(pullRequest), |
| 164 | 372 | body: pullRequest.body, |
| 165 | changedFiles: 0, | |
| 373 | changedFiles, | |
| 166 | 374 | mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, |
| 167 | 375 | closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, |
| 168 | 376 | reviewers: pullRequest.reviewers, |
| ... | ... | @@ -173,19 +381,26 @@ export const make = Effect.gen(function* () { |
| 173 | 381 | ...(pullRequest.autoMergeMethod === undefined |
| 174 | 382 | ? {} |
| 175 | 383 | : { autoMergeMethod: pullRequest.autoMergeMethod }), |
| 176 | })), | |
| 177 | ), | |
| 384 | }; | |
| 385 | return detail; | |
| 386 | }).pipe(Effect.mapError(fail("getChangeRequest"))), | |
| 178 | 387 | |
| 179 | 388 | getChangeRequestActivity: (input) => |
| 180 | 389 | cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( |
| 181 | 390 | Effect.mapError(fail("getChangeRequestActivity")), |
| 182 | 391 | Effect.flatMap((pullRequest) => |
| 183 | (pullRequest.threadsUrl === null | |
| 392 | (pullRequest.location === null | |
| 184 | 393 | ? 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 | ) | |
| 189 | 404 | ).pipe( |
| 190 | 405 | Effect.map((conversation): ProviderChangeRequestActivity => ({ |
| 191 | 406 | comments: conversation.comments, |
| ... | ... | @@ -202,16 +417,170 @@ export const make = Effect.gen(function* () { |
| 202 | 417 | // reach, so the answer is the same constant the detail carries. |
| 203 | 418 | getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), |
| 204 | 419 | |
| 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"))), | |
| 215 | 584 | |
| 216 | 585 | runAction: (input) => |
| 217 | 586 | cli |
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts+155| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Duration from "effect/Duration"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | 6 | |
| 5 | 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 6 | 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| ... | ... | @@ -362,6 +364,159 @@ layer("BitbucketPullRequestApi.layer", (it) => { |
| 362 | 364 | }), |
| 363 | 365 | ); |
| 364 | 366 | |
| 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 | ||
| 365 | 520 | it.effect("aggregates every diffstat page", () => |
| 366 | 521 | Effect.gen(function* () { |
| 367 | 522 | const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; |
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+99-20| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 1 | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Duration from "effect/Duration"; | |
| 2 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | |
| 3 | 6 | import * as Layer from "effect/Layer"; |
| 4 | 7 | import * as Result from "effect/Result"; |
| 5 | 8 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -19,6 +22,7 @@ import type { |
| 19 | 22 | } from "@t3tools/contracts"; |
| 20 | 23 | |
| 21 | 24 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 25 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | |
| 22 | 26 | import { |
| 23 | 27 | buildReviewThreads, |
| 24 | 28 | decodeCommentsJson, |
| ... | ... | @@ -135,7 +139,13 @@ const CONVERSATION_PAGE_SIZE = 50; |
| 135 | 139 | const CONVERSATION_PAGES = 10; |
| 136 | 140 | /** The same ceiling the gh and glab diff reads use. */ |
| 137 | 141 | const 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 | */ | |
| 147 | const REVISION_PATCH_TTL = Duration.seconds(5); | |
| 148 | const REVISION_PATCH_CAPACITY = 16; | |
| 139 | 149 | export interface BitbucketPullRequestBatch { |
| 140 | 150 | readonly items: ReadonlyArray<BitbucketPullRequest>; |
| 141 | 151 | readonly truncated: boolean; |
| ... | ... | @@ -182,6 +192,25 @@ export class BitbucketPullRequestApi extends Context.Service< |
| 182 | 192 | readonly number: number; |
| 183 | 193 | }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>; |
| 184 | 194 | |
| 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 | ||
| 185 | 214 | readonly getMergeability: (input: { |
| 186 | 215 | readonly repository: string; |
| 187 | 216 | readonly number: number; |
| ... | ... | @@ -525,6 +554,58 @@ export const make = Effect.gen(function* () { |
| 525 | 554 | }), |
| 526 | 555 | ); |
| 527 | 556 | |
| 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 | ||
| 528 | 609 | return BitbucketPullRequestApi.of({ |
| 529 | 610 | getViewer: () => |
| 530 | 611 | bitbucket.request({ method: "GET", url: "/user" }).pipe( |
| ... | ... | @@ -596,25 +677,23 @@ export const make = Effect.gen(function* () { |
| 596 | 677 | }), |
| 597 | 678 | ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), |
| 598 | 679 | |
| 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 | }), | |
| 618 | 697 | ), |
| 619 | 698 | |
| 620 | 699 | getDiffStat: (input) => |
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+14| ... | ... | @@ -31,6 +31,11 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 31 | 31 | }, |
| 32 | 32 | reviewers: { request: true, listCandidates: true }, |
| 33 | 33 | 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", | |
| 34 | 39 | }; |
| 35 | 40 | |
| 36 | 41 | /** |
| ... | ... | @@ -234,6 +239,15 @@ export const make = Effect.gen(function* () { |
| 234 | 239 | Effect.map((diff) => ({ ...diff, nextCursor: null })), |
| 235 | 240 | ), |
| 236 | 241 | |
| 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 | ||
| 237 | 251 | // Users only: Bitbucket requests a review of an account, and has no group that stands in for |
| 238 | 252 | // one on a pull request. |
| 239 | 253 | listReviewerCandidates: (input) => |
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts+13| ... | ... | @@ -4,6 +4,7 @@ import * as Result from "effect/Result"; |
| 4 | 4 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; |
| 5 | 5 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 6 | 6 | import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; |
| 7 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | |
| 7 | 8 | import { |
| 8 | 9 | PullRequestProviderError, |
| 9 | 10 | type ProviderChangeRequestDetail, |
| ... | ... | @@ -34,6 +35,7 @@ import { |
| 34 | 35 | |
| 35 | 36 | const CAPABILITIES: PullRequestCapabilities = { |
| 36 | 37 | diff: true, |
| 38 | viewedFiles: "environment", | |
| 37 | 39 | comment: true, |
| 38 | 40 | actions: ["merge", "close", "reopen", "update-branch"], |
| 39 | 41 | mergeMethods: ["merge", "squash", "rebase"], |
| ... | ... | @@ -347,6 +349,17 @@ export const make = Effect.gen(function* () { |
| 347 | 349 | }; |
| 348 | 350 | }, |
| 349 | 351 | ), |
| 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 | }), | |
| 350 | 363 | getDiff: (input) => |
| 351 | 364 | request({ |
| 352 | 365 | ...input, |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+256-7| ... | ... | @@ -2838,11 +2838,12 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2838 | 2838 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); |
| 2839 | 2839 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 2840 | 2840 | |
| 2841 | // Its own pull request: a node id looked up once is remembered for the life of the service. | |
| 2841 | 2842 | yield* cli.setReaction({ |
| 2842 | 2843 | cwd: "/w", |
| 2843 | 2844 | repository: "acme/web", |
| 2844 | 2845 | host: "github.com", |
| 2845 | number: 7, | |
| 2846 | number: 21, | |
| 2846 | 2847 | content: "rocket", |
| 2847 | 2848 | reacted: true, |
| 2848 | 2849 | }); |
| ... | ... | @@ -2851,7 +2852,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2851 | 2852 | const lookup = callAt(0).args; |
| 2852 | 2853 | expect(lookup).toContain("owner=acme"); |
| 2853 | 2854 | expect(lookup).toContain("name=web"); |
| 2854 | expect(lookup).toContain("number=7"); | |
| 2855 | expect(lookup).toContain("number=21"); | |
| 2855 | 2856 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 2856 | 2857 | const request = JSON.parse(callAt(1).stdin ?? "") as { |
| 2857 | 2858 | query: string; |
| ... | ... | @@ -2912,7 +2913,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2912 | 2913 | cwd: "/w", |
| 2913 | 2914 | repository: "acme/web", |
| 2914 | 2915 | host: "github.com", |
| 2915 | number: 7, | |
| 2916 | number: 22, | |
| 2916 | 2917 | ...fields, |
| 2917 | 2918 | }); |
| 2918 | 2919 | |
| ... | ... | @@ -2920,21 +2921,21 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 2920 | 2921 | yield* rewrite({ body: "A better description." }); |
| 2921 | 2922 | yield* rewrite({ title: "Both", body: "at once." }); |
| 2922 | 2923 | |
| 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. | |
| 2924 | 2925 | const variablesAt = (index: number) => |
| 2925 | 2926 | (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables; |
| 2926 | 2927 | expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); |
| 2927 | expect(variablesAt(3)).toEqual({ | |
| 2928 | expect(variablesAt(2)).toEqual({ | |
| 2928 | 2929 | pullRequestId: "PR_kwDOA", |
| 2929 | 2930 | body: "A better description.", |
| 2930 | 2931 | }); |
| 2931 | expect(variablesAt(5)).toEqual({ | |
| 2932 | expect(variablesAt(3)).toEqual({ | |
| 2932 | 2933 | pullRequestId: "PR_kwDOA", |
| 2933 | 2934 | title: "Both", |
| 2934 | 2935 | body: "at once.", |
| 2935 | 2936 | }); |
| 2936 | 2937 | // 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."); | |
| 2938 | 2939 | }), |
| 2939 | 2940 | ); |
| 2940 | 2941 | |
| ... | ... | @@ -3655,4 +3656,252 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3655 | 3656 | expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); |
| 3656 | 3657 | }), |
| 3657 | 3658 | ); |
| 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 | ); | |
| 3658 | 3907 | }); |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+123-2| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | type PullRequestAction, |
| 16 | 16 | type PullRequestStackHead, |
| 17 | 17 | type PullRequestActor, |
| 18 | type PullRequestFileViewed, | |
| 18 | 19 | type PullRequestInvolvement, |
| 19 | 20 | type PullRequestListFilters, |
| 20 | 21 | type PullRequestListState, |
| ... | ... | @@ -39,10 +40,12 @@ import { |
| 39 | 40 | ADD_REACTION_GRAPHQL_MUTATION, |
| 40 | 41 | buildReviewSubmissionJson, |
| 41 | 42 | buildReviewerRequestJson, |
| 43 | buildSetFilesViewedGraphQlMutation, | |
| 42 | 44 | decodeActorAvatarsJson, |
| 43 | 45 | decodePullRequestActivityJson, |
| 44 | 46 | decodePullRequestDetailJson, |
| 45 | 47 | decodePullRequestFilesJson, |
| 48 | decodePullRequestFilesViewedJson, | |
| 46 | 49 | decodePullRequestHeadsJson, |
| 47 | 50 | decodePullRequestListJson, |
| 48 | 51 | decodePullRequestNodeIdJson, |
| ... | ... | @@ -69,6 +72,7 @@ import { |
| 69 | 72 | decodeBaseComparisonJson, |
| 70 | 73 | PULL_REQUEST_DETAIL_JSON_FIELDS, |
| 71 | 74 | PULL_REQUEST_LIST_JSON_FIELDS, |
| 75 | PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, | |
| 72 | 76 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 73 | 77 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| 74 | 78 | REMOVE_REACTION_GRAPHQL_MUTATION, |
| ... | ... | @@ -371,6 +375,19 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; |
| 371 | 375 | |
| 372 | 376 | /** What the files API serves at most in one response, which is what one slice is made of. */ |
| 373 | 377 | const 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 | */ | |
| 383 | const 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 | */ | |
| 390 | export const NODE_ID_CACHE_CAPACITY = 128; | |
| 374 | 391 | |
| 375 | 392 | /** |
| 376 | 393 | * Pages of review threads to follow before the conversation is reported as truncated. GitHub |
| ... | ... | @@ -416,6 +433,12 @@ export interface GitHubPullRequestDiffSlice { |
| 416 | 433 | readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>; |
| 417 | 434 | } |
| 418 | 435 | |
| 436 | export 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 | ||
| 419 | 442 | export class GitHubPullRequestCli extends Context.Service< |
| 420 | 443 | GitHubPullRequestCli, |
| 421 | 444 | { |
| ... | ... | @@ -569,6 +592,29 @@ export class GitHubPullRequestCli extends Context.Service< |
| 569 | 592 | GitHubPullRequestCliError |
| 570 | 593 | >; |
| 571 | 594 | |
| 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 | ||
| 572 | 618 | readonly listReviewThreadComments: (input: { |
| 573 | 619 | readonly cwd: string; |
| 574 | 620 | readonly repository: string; |
| ... | ... | @@ -1123,15 +1169,30 @@ export const make = Effect.gen(function* () { |
| 1123 | 1169 | /** |
| 1124 | 1170 | * The pull request's own node id, which is what a mutation against the pull request itself is |
| 1125 | 1171 | * 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. | |
| 1126 | 1175 | */ |
| 1176 | const nodeIds = new Map<string, string>(); | |
| 1177 | ||
| 1127 | 1178 | const pullRequestNodeId = (input: { |
| 1128 | 1179 | readonly cwd: string; |
| 1129 | 1180 | readonly repository: string; |
| 1130 | 1181 | readonly host: string; |
| 1131 | 1182 | readonly number: number; |
| 1132 | 1183 | readonly operation: string; |
| 1133 | }) => { | |
| 1184 | }): Effect.Effect<string, GitHubPullRequestCliError> => { | |
| 1134 | 1185 | 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 | } | |
| 1135 | 1196 | return graphqlRead({ |
| 1136 | 1197 | cwd: input.cwd, |
| 1137 | 1198 | host: input.host, |
| ... | ... | @@ -1144,7 +1205,17 @@ export const make = Effect.gen(function* () { |
| 1144 | 1205 | ], |
| 1145 | 1206 | query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 1146 | 1207 | 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 | ); | |
| 1148 | 1219 | }; |
| 1149 | 1220 | |
| 1150 | 1221 | /** |
| ... | ... | @@ -2482,6 +2553,56 @@ export const make = Effect.gen(function* () { |
| 2482 | 2553 | variables: { threadId: input.threadId, body: input.body }, |
| 2483 | 2554 | }), |
| 2484 | 2555 | |
| 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 | ||
| 2485 | 2606 | setReviewThreadResolution: (input) => |
| 2486 | 2607 | graphql({ |
| 2487 | 2608 | cwd: input.cwd, |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+7| ... | ... | @@ -40,6 +40,7 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 40 | 40 | updateMethods: ["merge", "rebase"], |
| 41 | 41 | search: true, |
| 42 | 42 | reactions: true, |
| 43 | viewedFiles: "host", | |
| 43 | 44 | review: { |
| 44 | 45 | inlineComment: true, |
| 45 | 46 | reply: true, |
| ... | ... | @@ -576,6 +577,12 @@ export const make = Effect.gen(function* () { |
| 576 | 577 | getDiffFileContents: (input) => |
| 577 | 578 | cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), |
| 578 | 579 | |
| 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 | ||
| 579 | 586 | listReviewerCandidates: (input) => |
| 580 | 587 | cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), |
| 581 | 588 |
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts+167| ... | ... | @@ -1402,4 +1402,171 @@ layer("GitLabPullRequestCli.layer", (it) => { |
| 1402 | 1402 | expect(callAt(0).stdin).toBe('{"body":"true"}'); |
| 1403 | 1403 | }), |
| 1404 | 1404 | ); |
| 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 | ); | |
| 1405 | 1572 | }); |
apps/server/src/pullRequest/GitLabPullRequestCli.ts+101| ... | ... | @@ -35,8 +35,10 @@ import { |
| 35 | 35 | decodeOwnAwardIdJson, |
| 36 | 36 | decodeProjectMergeCapabilitiesJson, |
| 37 | 37 | decodeProjectUsersJson, |
| 38 | decodeRepositoryBlobsJson, | |
| 38 | 39 | decodeViewerJson, |
| 39 | 40 | gitLabAwardName, |
| 41 | REPOSITORY_BLOBS_GRAPHQL_QUERY, | |
| 40 | 42 | type GitLabDiffRefs, |
| 41 | 43 | type GitLabMergeRequestDetail, |
| 42 | 44 | type GitLabMergeRequestListItem, |
| ... | ... | @@ -283,6 +285,21 @@ export class GitLabPullRequestCli extends Context.Service< |
| 283 | 285 | readonly repository: string; |
| 284 | 286 | }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>; |
| 285 | 287 | |
| 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 | ||
| 286 | 303 | /** |
| 287 | 304 | * Who this merge request may be sent to, and who it has already been sent to. Two reads at |
| 288 | 305 | * 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* () { |
| 1031 | 1048 | }), |
| 1032 | 1049 | ); |
| 1033 | 1050 | |
| 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 | ||
| 1034 | 1133 | const viewerUsername = (input: { readonly cwd: string }) => |
| 1035 | 1134 | api({ cwd: input.cwd, path: "user" }).pipe( |
| 1036 | 1135 | Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => { |
| ... | ... | @@ -1066,6 +1165,8 @@ export const make = Effect.gen(function* () { |
| 1066 | 1165 | |
| 1067 | 1166 | listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), |
| 1068 | 1167 | |
| 1168 | getFileRevisions: fileRevisions, | |
| 1169 | ||
| 1069 | 1170 | setReaction: (input) => |
| 1070 | 1171 | Effect.gen(function* () { |
| 1071 | 1172 | const subject = awardSubjectPath(input); |
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+14| ... | ... | @@ -35,6 +35,11 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 35 | 35 | updateMethods: ["rebase"], |
| 36 | 36 | search: true, |
| 37 | 37 | 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", | |
| 38 | 43 | review: { |
| 39 | 44 | inlineComment: true, |
| 40 | 45 | reply: true, |
| ... | ... | @@ -218,6 +223,15 @@ export const make = Effect.gen(function* () { |
| 218 | 223 | |
| 219 | 224 | getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), |
| 220 | 225 | |
| 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 | ||
| 221 | 235 | // Users only: GitLab requests a review of a person, and the groups that can stand in for one |
| 222 | 236 | // appear in approval rules rather than in a merge request's reviewers. |
| 223 | 237 | listReviewerCandidates: (input) => |
apps/server/src/pullRequest/PullRequestProvider.ts+61| ... | ... | @@ -10,6 +10,7 @@ import type { |
| 10 | 10 | PullRequestChecksState, |
| 11 | 11 | PullRequestCheck, |
| 12 | 12 | PullRequestComment, |
| 13 | PullRequestFileViewed, | |
| 13 | 14 | PullRequestCommit, |
| 14 | 15 | PullRequestInvolvement, |
| 15 | 16 | PullRequestLabel, |
| ... | ... | @@ -258,6 +259,32 @@ export interface ProviderDiffFileContents { |
| 258 | 259 | readonly newContents: string; |
| 259 | 260 | } |
| 260 | 261 | |
| 262 | export 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 | */ | |
| 277 | export 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 | ||
| 261 | 288 | export interface ProviderRepositoryRef { |
| 262 | 289 | readonly cwd: string; |
| 263 | 290 | /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ |
| ... | ... | @@ -448,6 +475,40 @@ export interface PullRequestProviderApi { |
| 448 | 475 | }, |
| 449 | 476 | ) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>; |
| 450 | 477 | |
| 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 | ||
| 451 | 512 | readonly runAction: ( |
| 452 | 513 | input: ProviderRepositoryRef & { |
| 453 | 514 | readonly number: number; |
apps/server/src/pullRequest/PullRequestService.test.ts+1422-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 2 | 3 | import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; |
| 3 | 4 | import { assert, it } from "@effect/vitest"; |
| 4 | 5 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -18,8 +19,12 @@ import type { |
| 18 | 19 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 19 | 20 | |
| 20 | 21 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 22 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 23 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | |
| 21 | 24 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 22 | 25 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 26 | import { ForgejoCli } from "../sourceControl/ForgejoCli.ts"; | |
| 27 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | |
| 23 | 28 | import { |
| 24 | 29 | PullRequestProviderError, |
| 25 | 30 | type ProviderChangeRequest, |
| ... | ... | @@ -28,6 +33,10 @@ import { |
| 28 | 33 | import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; |
| 29 | 34 | import * as PullRequestService from "./PullRequestService.ts"; |
| 30 | 35 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; |
| 36 | import { | |
| 37 | FILE_REVISIONS_CACHE_CAPACITY, | |
| 38 | MAX_FILE_REVISION_PATHS, | |
| 39 | } from "./pullRequestViewedFiles.ts"; | |
| 31 | 40 | |
| 32 | 41 | function project(input: { |
| 33 | 42 | readonly id: string; |
| ... | ... | @@ -184,8 +193,10 @@ function makeService(input: { |
| 184 | 193 | readonly providers: ReadonlyArray<PullRequestProviderApi>; |
| 185 | 194 | readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; |
| 186 | 195 | }) { |
| 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( | |
| 189 | 200 | Layer.mergeAll( |
| 190 | 201 | Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), |
| 191 | 202 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| ... | ... | @@ -202,12 +213,16 @@ function makeService(input: { |
| 202 | 213 | Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), |
| 203 | 214 | }), |
| 204 | 215 | 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)), | |
| 205 | 219 | Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( |
| 206 | 220 | Layer.provide(KeyValueStore.layerMemory), |
| 207 | 221 | Layer.provide(NodeServices.layer), |
| 208 | 222 | ), |
| 209 | 223 | ), |
| 210 | 224 | ), |
| 225 | (context) => Effect.provideContext(PullRequestService.make, context), | |
| 211 | 226 | ); |
| 212 | 227 | } |
| 213 | 228 | |
| ... | ... | @@ -4002,7 +4017,7 @@ it.effect("shares linked summaries and reuses them for display without asking th |
| 4002 | 4017 | |
| 4003 | 4018 | it.effect("keeps routed reads separate when the GitHub account changes", () => |
| 4004 | 4019 | Effect.gen(function* () { |
| 4005 | for (const operation of ["summary", "detail", "diff"] as const) { | |
| 4020 | for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { | |
| 4006 | 4021 | let failing = false; |
| 4007 | 4022 | let calls = 0; |
| 4008 | 4023 | const read = () => |
| ... | ... | @@ -4018,6 +4033,14 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => |
| 4018 | 4033 | ], |
| 4019 | 4034 | providers: [ |
| 4020 | 4035 | 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 | ), | |
| 4021 | 4044 | getChangeRequestSummary: read, |
| 4022 | 4045 | getChangeRequest: read, |
| 4023 | 4046 | getDiff: () => |
| ... | ... | @@ -4048,7 +4071,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => |
| 4048 | 4071 | |
| 4049 | 4072 | it.effect("isolates routed caches for two credentials belonging to the same account", () => |
| 4050 | 4073 | Effect.gen(function* () { |
| 4051 | for (const operation of ["summary", "detail", "diff"] as const) { | |
| 4074 | for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { | |
| 4052 | 4075 | let credential = "broad"; |
| 4053 | 4076 | let calls = 0; |
| 4054 | 4077 | const read = () => |
| ... | ... | @@ -4064,6 +4087,14 @@ it.effect("isolates routed caches for two credentials belonging to the same acco |
| 4064 | 4087 | ], |
| 4065 | 4088 | providers: [ |
| 4066 | 4089 | 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 | ), | |
| 4067 | 4098 | withVerifiedCredential: (_, use) => |
| 4068 | 4099 | Effect.suspend(() => |
| 4069 | 4100 | use({ |
| ... | ... | @@ -5089,6 +5120,1393 @@ it.effect("names the signed-in account in the detail, and says nothing where the |
| 5089 | 5120 | }), |
| 5090 | 5121 | ); |
| 5091 | 5122 | |
| 5123 | it.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 | ||
| 5183 | const 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 | ||
| 5218 | const 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 | ||
| 5236 | const GITLAB_REFERENCE = { | |
| 5237 | projectId: "p1" as ProjectId, | |
| 5238 | repository: "group/project", | |
| 5239 | number: 1, | |
| 5240 | }; | |
| 5241 | ||
| 5242 | it.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 | ||
| 5349 | it.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 | ||
| 5425 | it.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 | ||
| 5467 | it.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 | ||
| 5529 | it.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 | ||
| 5547 | it.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 | ||
| 5574 | it.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 | ||
| 5609 | it.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 | ||
| 5650 | it.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 | ||
| 5680 | it.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 | ||
| 5701 | it.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 | ||
| 5716 | it.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 | ||
| 5751 | it.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 | ||
| 5774 | it.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 | ||
| 5805 | it.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 | ||
| 5825 | it.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 | ||
| 5845 | it.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 | ||
| 5898 | it.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. */ | |
| 5963 | const 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 | ||
| 5987 | const 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 | ||
| 6006 | const AZURE_PLATFORM = { projectId: "p1" as ProjectId, repository: "web", number: 1 }; | |
| 6007 | const AZURE_OTHER = { projectId: "p2" as ProjectId, repository: "web", number: 1 }; | |
| 6008 | ||
| 6009 | it.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 | ||
| 6027 | it.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 | ||
| 6041 | it.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 | ||
| 6072 | it.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 | ||
| 6124 | it.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. */ | |
| 6150 | const 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 | ||
| 6167 | it.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 | ||
| 6185 | it.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 | ||
| 6224 | it.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 | ||
| 6250 | it.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 | ||
| 6313 | it.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 | ||
| 6376 | it.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 | ||
| 6433 | it.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 | ||
| 6479 | it.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 | ||
| 5092 | 6510 | it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => |
| 5093 | 6511 | Effect.gen(function* () { |
| 5094 | 6512 | const seen: string[] = []; |
apps/server/src/pullRequest/PullRequestService.ts+177-12| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | isSshRemoteUrl, |
| 4 | 4 | sourceControlRepositorySelector, |
| 5 | 5 | } from "@t3tools/shared/sourceControl"; |
| 6 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 6 | 7 | import * as Cache from "effect/Cache"; |
| 7 | 8 | import * as Clock from "effect/Clock"; |
| 8 | 9 | import * as Context from "effect/Context"; |
| ... | ... | @@ -35,6 +36,7 @@ import { |
| 35 | 36 | type PullRequestDiffFileContentsResult, |
| 36 | 37 | type PullRequestDiffStat, |
| 37 | 38 | type PullRequestDiffInput, |
| 39 | type PullRequestFilesViewedResult, | |
| 38 | 40 | type PullRequestDiffResult, |
| 39 | 41 | type PullRequestInvalidateInput, |
| 40 | 42 | type PullRequestListEntry, |
| ... | ... | @@ -55,6 +57,7 @@ import { |
| 55 | 57 | type PullRequestReviewerRequestInput, |
| 56 | 58 | type PullRequestLabelCandidateList, |
| 57 | 59 | type PullRequestLabelChangeInput, |
| 60 | type PullRequestSetFilesViewedInput, | |
| 58 | 61 | type PullRequestSubmitReviewInput, |
| 59 | 62 | PullRequestStack, |
| 60 | 63 | PullRequestSummary, |
| ... | ... | @@ -70,6 +73,7 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source |
| 70 | 73 | |
| 71 | 74 | import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; |
| 72 | 75 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 76 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | |
| 73 | 77 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 74 | 78 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; |
| 75 | 79 | import { |
| ... | ... | @@ -80,6 +84,7 @@ import { |
| 80 | 84 | } from "./PullRequestProvider.ts"; |
| 81 | 85 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; |
| 82 | 86 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; |
| 87 | import * as ViewedFiles from "./pullRequestViewedFiles.ts"; | |
| 83 | 88 | |
| 84 | 89 | export interface PullRequestMergeEvent extends PullRequestRef { |
| 85 | 90 | readonly mergedAt: string; |
| ... | ... | @@ -128,6 +133,12 @@ const DIFF_CACHE_TTL = Duration.seconds(60); |
| 128 | 133 | const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); |
| 129 | 134 | /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ |
| 130 | 135 | const 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 | */ | |
| 141 | const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); | |
| 131 | 142 | /** A diff can stay interactive while its next cached value is fetched off the critical path. */ |
| 132 | 143 | const DIFF_STALE_WINDOW = Duration.minutes(10); |
| 133 | 144 | /** How long one host's signed-in login is believed without asking its CLI again. */ |
| ... | ... | @@ -139,6 +150,7 @@ const LIST_CACHE_CAPACITY = 64; |
| 139 | 150 | const LIST_STATS_CACHE_CAPACITY = 32; |
| 140 | 151 | const DETAIL_CACHE_CAPACITY = 128; |
| 141 | 152 | const DIFF_CACHE_CAPACITY = 128; |
| 153 | const FILES_VIEWED_CACHE_CAPACITY = 128; | |
| 142 | 154 | const VIEWER_CACHE_CAPACITY = 32; |
| 143 | 155 | |
| 144 | 156 | export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; |
| ... | ... | @@ -202,6 +214,12 @@ export class PullRequestService extends Context.Service< |
| 202 | 214 | readonly diffFileContents: ( |
| 203 | 215 | input: PullRequestDiffFileContentsInput, |
| 204 | 216 | ) => 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>; | |
| 205 | 223 | readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>; |
| 206 | 224 | readonly update: (input: PullRequestUpdateInput) => Effect.Effect<void, PullRequestError>; |
| 207 | 225 | 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 |
| 281 | 299 | const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; |
| 282 | 300 | |
| 283 | 301 | /** A project this page can read: its remote is on a host with an implementation. */ |
| 284 | interface SupportedProject { | |
| 302 | export interface SupportedProject { | |
| 285 | 303 | readonly cursorKey: string; |
| 286 | 304 | readonly project: OrchestrationProjectShell; |
| 287 | 305 | readonly api: PullRequestProviderApi; |
| 288 | 306 | readonly repository: string; |
| 289 | 307 | /** The host the repository lives on, which is the account boundary rather than the kind. */ |
| 290 | 308 | 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; | |
| 291 | 314 | } |
| 292 | 315 | |
| 293 | 316 | /** |
| ... | ... | @@ -448,6 +471,7 @@ function withRateLimitBackoff( |
| 448 | 471 | api: PullRequestProviderApi, |
| 449 | 472 | host: string, |
| 450 | 473 | limits: SourceControlRateLimit.SourceControlRateLimit["Service"], |
| 474 | options?: { readonly viewerAllowsPause: boolean }, | |
| 451 | 475 | ): PullRequestProviderApi { |
| 452 | 476 | const key = { provider: api.kind, host }; |
| 453 | 477 | const protect = <A>( |
| ... | ... | @@ -499,7 +523,13 @@ function withRateLimitBackoff( |
| 499 | 523 | const wrapped = { |
| 500 | 524 | kind: api.kind, |
| 501 | 525 | 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), | |
| 503 | 533 | ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }), |
| 504 | 534 | ...(api.withVerifiedCredential === undefined |
| 505 | 535 | ? {} |
| ... | ... | @@ -535,6 +565,15 @@ function withRateLimitBackoff( |
| 535 | 565 | ...(api.getDiffFileContents === undefined |
| 536 | 566 | ? {} |
| 537 | 567 | : { 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) }), | |
| 538 | 577 | runAction: interactive("runAction", api.runAction), |
| 539 | 578 | ...(api.updateChangeRequest === undefined |
| 540 | 579 | ? {} |
| ... | ... | @@ -568,6 +607,7 @@ export const make = Effect.gen(function* () { |
| 568 | 607 | const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 569 | 608 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 570 | 609 | const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; |
| 610 | const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; | |
| 571 | 611 | const readCache = yield* PullRequestReadCache.PullRequestReadCache; |
| 572 | 612 | |
| 573 | 613 | const refineUnknownProjectKinds = ( |
| ... | ... | @@ -727,6 +767,10 @@ export const make = Effect.gen(function* () { |
| 727 | 767 | api: withRateLimitBackoff(api, host, rateLimits), |
| 728 | 768 | repository, |
| 729 | 769 | host, |
| 770 | remote: | |
| 771 | kind === "azure-devops" | |
| 772 | ? identity.canonicalKey | |
| 773 | : normalizeGitRemoteUrl(`https://${host}/${repository}`), | |
| 730 | 774 | }); |
| 731 | 775 | } |
| 732 | 776 | return { supported, unimplemented, viewerRoots }; |
| ... | ... | @@ -793,7 +837,14 @@ export const make = Effect.gen(function* () { |
| 793 | 837 | ); |
| 794 | 838 | } |
| 795 | 839 | 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 | }, | |
| 797 | 848 | ); |
| 798 | 849 | }), |
| 799 | 850 | ); |
| ... | ... | @@ -890,7 +941,10 @@ export const make = Effect.gen(function* () { |
| 890 | 941 | if (registered === null) { |
| 891 | 942 | return Effect.die(new Error(`Missing pull request provider: ${kind}`)); |
| 892 | 943 | } |
| 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 }); | |
| 894 | 948 | return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe( |
| 895 | 949 | Effect.map((viewer) => ({ |
| 896 | 950 | host, |
| ... | ... | @@ -923,6 +977,7 @@ export const make = Effect.gen(function* () { |
| 923 | 977 | const resolveViewers = ( |
| 924 | 978 | projects: ReadonlyArray<SupportedProject>, |
| 925 | 979 | viewerRoots: WorkspaceProjects["viewerRoots"], |
| 980 | options?: { readonly allowPaused: boolean }, | |
| 926 | 981 | ) => |
| 927 | 982 | Effect.forEach( |
| 928 | 983 | [...new Set(projects.map(({ host }) => host))], |
| ... | ... | @@ -938,8 +993,33 @@ export const make = Effect.gen(function* () { |
| 938 | 993 | // unreadable worktree would otherwise report the whole host as signed out. |
| 939 | 994 | const roots = |
| 940 | 995 | 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. | |
| 941 | 999 | 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 | ); | |
| 943 | 1023 | }), |
| 944 | 1024 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 945 | 1025 | ); |
| ... | ... | @@ -1697,6 +1777,43 @@ export const make = Effect.gen(function* () { |
| 1697 | 1777 | }), |
| 1698 | 1778 | ); |
| 1699 | 1779 | |
| 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 | ||
| 1700 | 1817 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => |
| 1701 | 1818 | requireProject(input).pipe( |
| 1702 | 1819 | Effect.flatMap((project): Effect.Effect<string, PullRequestError> => { |
| ... | ... | @@ -2334,8 +2451,6 @@ export const make = Effect.gen(function* () { |
| 2334 | 2451 | return { stats: stats.flat() }; |
| 2335 | 2452 | }); |
| 2336 | 2453 | |
| 2337 | const context = yield* Effect.context<never>(); | |
| 2338 | const runFork = Effect.runForkWith(context); | |
| 2339 | 2454 | const revalidate = <A, E>(read: Effect.Effect<A, E>) => |
| 2340 | 2455 | Effect.context<never>().pipe( |
| 2341 | 2456 | Effect.flatMap((caller) => |
| ... | ... | @@ -2494,14 +2609,34 @@ export const make = Effect.gen(function* () { |
| 2494 | 2609 | if (oldest !== undefined) recentStats.delete(oldest); |
| 2495 | 2610 | } |
| 2496 | 2611 | }; |
| 2497 | const bumpRefEpoch = (ref: PullRequestRef) => { | |
| 2612 | const bumpEpoch = (epochs: Map<string, number>, ref: PullRequestRef) => { | |
| 2498 | 2613 | 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); | |
| 2502 | 2617 | } |
| 2503 | refEpochs.set(scope, ++epochCounter); | |
| 2618 | epochs.set(scope, ++epochCounter); | |
| 2504 | 2619 | }; |
| 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 | }); | |
| 2505 | 2640 | |
| 2506 | 2641 | /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ |
| 2507 | 2642 | const filtersOfKey = ( |
| ... | ... | @@ -2785,6 +2920,25 @@ export const make = Effect.gen(function* () { |
| 2785 | 2920 | return staleDiff(key, Cache.get(diffCache, key)); |
| 2786 | 2921 | }; |
| 2787 | 2922 | |
| 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 | ||
| 2788 | 2942 | const listStatsCache = yield* Cache.makeWith( |
| 2789 | 2943 | (key: string) => { |
| 2790 | 2944 | const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number, number]>]; |
| ... | ... | @@ -2846,6 +3000,14 @@ export const make = Effect.gen(function* () { |
| 2846 | 3000 | "PullRequestService.invalidate", |
| 2847 | 3001 | )(function* (input, options) { |
| 2848 | 3002 | 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 | } | |
| 2849 | 3011 | if (reference !== undefined) { |
| 2850 | 3012 | yield* canonicalRef(reference).pipe( |
| 2851 | 3013 | Effect.flatMap((ref) => |
| ... | ... | @@ -2857,6 +3019,7 @@ export const make = Effect.gen(function* () { |
| 2857 | 3019 | ); |
| 2858 | 3020 | } else { |
| 2859 | 3021 | listingsEpoch = ++epochCounter; |
| 3022 | everyFileRevisionEpoch = ++epochCounter; | |
| 2860 | 3023 | viewersByHost.clear(); |
| 2861 | 3024 | yield* Cache.invalidateAll(viewerFlights); |
| 2862 | 3025 | } |
| ... | ... | @@ -2971,6 +3134,8 @@ export const make = Effect.gen(function* () { |
| 2971 | 3134 | threadComments, |
| 2972 | 3135 | diff: credentialCached(diff), |
| 2973 | 3136 | diffFileContents, |
| 3137 | filesViewed: credentialCached(filesViewed), | |
| 3138 | setFilesViewed, | |
| 2974 | 3139 | runAction: runActionAndInvalidate, |
| 2975 | 3140 | update: invalidatedByMutation(update), |
| 2976 | 3141 | comment: invalidatedByMutation(comment), |
apps/server/src/pullRequest/azureDevOpsDiff.test.ts created+459| ... | ... | @@ -0,0 +1,459 @@ |
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | azureDevOpsFilePatch, | |
| 6 | azureDevOpsUnreadableFilePatch, | |
| 7 | byteLength, | |
| 8 | formatAzureDevOpsDiffCursor, | |
| 9 | MAX_FILE_DIFF_EDITS, | |
| 10 | parseAzureDevOpsDiffCursor, | |
| 11 | } from "./azureDevOpsDiff.ts"; | |
| 12 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; | |
| 13 | ||
| 14 | function 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 | ||
| 25 | function texts(oldContents: string, newContents: string, binary = false) { | |
| 26 | return { oldContents, newContents, binary }; | |
| 27 | } | |
| 28 | ||
| 29 | describe("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 | ||
| 343 | describe("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 | ||
| 354 | describe("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 | ||
| 436 | describe("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 @@ |
| 1 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | import { structuredPatch } from "diff"; | |
| 3 | ||
| 4 | import 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 | */ | |
| 11 | export interface AzureDevOpsDiffCursor { | |
| 12 | readonly iterationId: number; | |
| 13 | readonly fileIndex: number; | |
| 14 | } | |
| 15 | ||
| 16 | const CURSOR_SEPARATOR = ":"; | |
| 17 | ||
| 18 | /** | |
| 19 | * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as | |
| 20 | * three. | |
| 21 | */ | |
| 22 | const CURSOR_COMPONENT = /^\d+$/; | |
| 23 | ||
| 24 | export 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. */ | |
| 29 | export 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. */ | |
| 45 | export 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 | ||
| 52 | export 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 | */ | |
| 72 | const 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. */ | |
| 75 | const 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 | */ | |
| 83 | export 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 | */ | |
| 90 | const MAX_FILE_DIFF_MILLIS = 2_000; | |
| 91 | ||
| 92 | /** How much diff work one slice does before the rest is left for the next request. */ | |
| 93 | export const MAX_DIFF_SLICE_EDITS = 6_000; | |
| 94 | ||
| 95 | /** How much patch one slice carries before the rest is left for the next request. */ | |
| 96 | export 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 | */ | |
| 102 | export const MAX_DIFF_SLICE_FILES = 300; | |
| 103 | ||
| 104 | /** Git's own note for a side whose last line has no newline after it. */ | |
| 105 | const 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. */ | |
| 108 | function 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. */ | |
| 116 | function 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 | */ | |
| 124 | export 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 | */ | |
| 130 | function 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 | */ | |
| 141 | function 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 | */ | |
| 164 | function 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 | */ | |
| 184 | export 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. */ | |
| 269 | export 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"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | decodeItemContentJson, | |
| 6 | decodeIterationChangesJson, | |
| 7 | decodeIterationsJson, | |
| 5 | 8 | decodePullRequestJson, |
| 6 | 9 | decodePullRequestListJson, |
| 7 | 10 | decodeThreadsJson, |
| ... | ... | @@ -187,17 +190,15 @@ describe("decodePullRequestJson", () => { |
| 187 | 190 | expect(unspecified?.autoMergeMethod).toBeUndefined(); |
| 188 | 191 | }); |
| 189 | 192 | |
| 190 | it("works out where the conversation lives from what Azure returned", () => { | |
| 193 | it("works out where the repository lives from what Azure returned", () => { | |
| 191 | 194 | const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); |
| 192 | 195 | |
| 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" }); | |
| 196 | 197 | }); |
| 197 | 198 | |
| 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. | |
| 201 | 202 | const detail = expectSuccess( |
| 202 | 203 | decodePullRequestJson( |
| 203 | 204 | asJson( |
| ... | ... | @@ -212,7 +213,7 @@ describe("decodePullRequestJson", () => { |
| 212 | 213 | ), |
| 213 | 214 | ); |
| 214 | 215 | |
| 215 | expect(detail?.threadsUrl).toBeNull(); | |
| 216 | expect(detail?.location).toBeNull(); | |
| 216 | 217 | }); |
| 217 | 218 | |
| 218 | 219 | it("returns nothing when Azure gave no way to place the pull request at all", () => { |
| ... | ... | @@ -341,3 +342,311 @@ describe("decodeThreadsJson", () => { |
| 341 | 342 | expect(comments).toEqual([]); |
| 342 | 343 | }); |
| 343 | 344 | }); |
| 345 | ||
| 346 | describe("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 | ||
| 379 | describe("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 | ||
| 527 | describe("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 | */ | |
| 559 | describe("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 { |
| 12 | 12 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 13 | 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 14 | 14 | |
| 15 | import { | |
| 16 | azureDevOpsOrganizationBaseFromRestApiUrl, | |
| 17 | azureDevOpsPullRequestWebUrl, | |
| 18 | } from "../sourceControl/azureDevOpsPullRequests.ts"; | |
| 15 | import { azureDevOpsPullRequestWebUrl } from "../sourceControl/azureDevOpsPullRequests.ts"; | |
| 19 | 16 | |
| 20 | 17 | /** |
| 21 | 18 | * 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({ |
| 117 | 114 | ), |
| 118 | 115 | }); |
| 119 | 116 | |
| 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 | */ | |
| 122 | export interface AzureDevOpsRepositoryLocation { | |
| 123 | readonly project: string; | |
| 124 | readonly repository: string; | |
| 125 | } | |
| 126 | ||
| 120 | 127 | export interface AzureDevOpsPullRequest { |
| 121 | 128 | readonly number: number; |
| 122 | 129 | readonly title: string; |
| ... | ... | @@ -137,8 +144,8 @@ export interface AzureDevOpsPullRequest { |
| 137 | 144 | readonly body: string; |
| 138 | 145 | readonly reviewRequestLogins: ReadonlyArray<string>; |
| 139 | 146 | 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; | |
| 142 | 149 | /** Whether Azure is set to complete this on its own once its policies pass. */ |
| 143 | 150 | readonly autoMergeEnabled: boolean; |
| 144 | 151 | /** The completion strategy Azure stored with auto-complete, where it reported one. */ |
| ... | ... | @@ -188,15 +195,16 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili |
| 188 | 195 | } |
| 189 | 196 | |
| 190 | 197 | /** |
| 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. | |
| 193 | 200 | */ |
| 194 | function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): string | null { | |
| 195 | const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); | |
| 201 | function toLocation( | |
| 202 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 203 | ): AzureDevOpsRepositoryLocation | null { | |
| 196 | 204 | const project = trimmed(raw.repository?.project?.name); |
| 197 | 205 | 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 }; | |
| 200 | 208 | } |
| 201 | 209 | |
| 202 | 210 | function toAutoMergeMethod( |
| ... | ... | @@ -259,7 +267,7 @@ function toPullRequest( |
| 259 | 267 | body: raw.description ?? "", |
| 260 | 268 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), |
| 261 | 269 | reviewers, |
| 262 | threadsUrl: toThreadsUrl(raw), | |
| 270 | location: toLocation(raw), | |
| 263 | 271 | autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, |
| 264 | 272 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), |
| 265 | 273 | }; |
| ... | ... | @@ -370,3 +378,197 @@ export function decodeThreadsJson( |
| 370 | 378 | comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), |
| 371 | 379 | ); |
| 372 | 380 | } |
| 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 | */ | |
| 387 | const 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 | ||
| 397 | const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); | |
| 398 | ||
| 399 | const 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 | ||
| 418 | const 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 | ||
| 424 | const 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. */ | |
| 433 | export 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 | */ | |
| 443 | export 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 | */ | |
| 456 | export 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. */ | |
| 462 | export interface AzureDevOpsItemContent { | |
| 463 | readonly contents: string; | |
| 464 | readonly isBinary: boolean; | |
| 465 | } | |
| 466 | ||
| 467 | const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); | |
| 468 | const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); | |
| 469 | const decodeChangePage = decodeJsonResult(RawChangePageSchema); | |
| 470 | const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); | |
| 471 | const 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 | */ | |
| 478 | function 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 | */ | |
| 489 | function 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 | ||
| 506 | export 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 | ||
| 525 | export 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 | */ | |
| 564 | export 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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; | |
| 4 | ||
| 5 | function patchOf(...lines: ReadonlyArray<string>): string { | |
| 6 | return `${lines.join("\n")}\n`; | |
| 7 | } | |
| 8 | ||
| 9 | describe("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 @@ |
| 1 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 2 | ||
| 3 | const ENTRY = "diff --git "; | |
| 4 | const QUOTE = '"'; | |
| 5 | ||
| 6 | interface 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. */ | |
| 16 | function 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 | */ | |
| 33 | function 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 | ||
| 41 | function 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 | */ | |
| 52 | function 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>`. */ | |
| 73 | function 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 | */ | |
| 88 | export 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 { |
| 6 | 6 | buildPullRequestStackMembershipsGraphQlQuery, |
| 7 | 7 | decodePullRequestStackMembershipsJson, |
| 8 | 8 | buildReviewerRequestJson, |
| 9 | buildSetFilesViewedGraphQlMutation, | |
| 9 | 10 | decodeBaseComparisonJson, |
| 10 | 11 | decodePullRequestActivityJson, |
| 11 | 12 | decodePullRequestDetailJson, |
| 12 | 13 | decodePullRequestFilesJson, |
| 14 | decodePullRequestFilesViewedJson, | |
| 13 | 15 | decodePullRequestListJson, |
| 14 | 16 | decodePullRequestNodeIdJson, |
| 15 | 17 | decodePullRequestSearchJson, |
| ... | ... | @@ -1360,6 +1362,57 @@ describe("review submission payload", () => { |
| 1360 | 1362 | }); |
| 1361 | 1363 | |
| 1362 | 1364 | describe("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 | ||
| 1363 | 1416 | it("assembles a unified patch the files API does not return", () => { |
| 1364 | 1417 | const result = expectSuccess( |
| 1365 | 1418 | decodePullRequestFilesJson( |
| ... | ... | @@ -1528,6 +1581,100 @@ describe("how far a branch trails its base", () => { |
| 1528 | 1581 | }); |
| 1529 | 1582 | }); |
| 1530 | 1583 | |
| 1584 | describe("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 | ||
| 1641 | describe("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 | ||
| 1531 | 1678 | describe("host-native stack decoding", () => { |
| 1532 | 1679 | /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ |
| 1533 | 1680 | function stack(overrides: Record<string, unknown> = {}) { |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+125-5| ... | ... | @@ -10,6 +10,7 @@ import type { |
| 10 | 10 | PullRequestChecksState, |
| 11 | 11 | PullRequestComment, |
| 12 | 12 | PullRequestCommit, |
| 13 | PullRequestFileViewedState, | |
| 13 | 14 | PullRequestLabel, |
| 14 | 15 | PullRequestMergeCapabilities, |
| 15 | 16 | PullRequestMergeMethod, |
| ... | ... | @@ -30,6 +31,7 @@ import type { |
| 30 | 31 | PullRequestState, |
| 31 | 32 | PullRequestThreadComment, |
| 32 | 33 | } from "@t3tools/contracts"; |
| 34 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 33 | 35 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 34 | 36 | |
| 35 | 37 | import { dedupeChecks } from "./pullRequestChecks.ts"; |
| ... | ... | @@ -2510,16 +2512,21 @@ export function decodePullRequestFilesJson( |
| 2510 | 2512 | } |
| 2511 | 2513 | // A rename counts its hunks against the old path, which is the only place it is named. |
| 2512 | 2514 | const oldPath = |
| 2513 | status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename; | |
| 2515 | status === "renamed" ? value.previous_filename || value.filename : value.filename; | |
| 2514 | 2516 | const header = [ |
| 2515 | `diff --git a/${oldPath} b/${value.filename}`, | |
| 2517 | `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, | |
| 2516 | 2518 | // The files API reports no file mode, so the ordinary one stands in: the viewer reads |
| 2517 | 2519 | // these lines as "added" and "removed" rather than for the mode they carry. |
| 2518 | 2520 | ...(status === "added" ? ["new file mode 100644"] : []), |
| 2519 | 2521 | ...(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}`)}`, | |
| 2523 | 2530 | ].join("\n"); |
| 2524 | 2531 | sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); |
| 2525 | 2532 | } |
| ... | ... | @@ -2531,6 +2538,119 @@ export function decodePullRequestFilesJson( |
| 2531 | 2538 | }); |
| 2532 | 2539 | } |
| 2533 | 2540 | |
| 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 | */ | |
| 2546 | export 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 | ||
| 2557 | const 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 | ||
| 2588 | const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema); | |
| 2589 | ||
| 2590 | export 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. */ | |
| 2600 | function 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 | ||
| 2611 | export 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 | */ | |
| 2637 | export 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 | ||
| 2534 | 2654 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ |
| 2535 | 2655 | const RawStackPullRequestSchema = Schema.Struct({ |
| 2536 | 2656 | 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"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | decodeAwardEmojiJson, |
| 6 | decodeRepositoryBlobsJson, | |
| 6 | 7 | decodeCommitsJson, |
| 7 | 8 | decodeMergeRequestDetailJson, |
| 8 | 9 | decodeMergeRequestDiffsJson, |
| ... | ... | @@ -381,6 +382,57 @@ describe("decodeCommitsJson", () => { |
| 381 | 382 | }); |
| 382 | 383 | |
| 383 | 384 | describe("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 | ||
| 384 | 436 | it("assembles a unified patch GitLab does not return", () => { |
| 385 | 437 | const result = expectSuccess( |
| 386 | 438 | decodeMergeRequestDiffsJson( |
| ... | ... | @@ -630,3 +682,122 @@ describe("gitLabAwardName", () => { |
| 630 | 682 | expect(gitLabAwardName("hooray")).toBe("tada"); |
| 631 | 683 | }); |
| 632 | 684 | }); |
| 685 | ||
| 686 | describe("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 { |
| 19 | 19 | PullRequestState, |
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | 21 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 22 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 22 | 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 23 | 24 | |
| 24 | 25 | /** |
| ... | ... | @@ -680,8 +681,8 @@ function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { |
| 680 | 681 | readonly to: string; |
| 681 | 682 | } { |
| 682 | 683 | 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}`), | |
| 685 | 686 | }; |
| 686 | 687 | } |
| 687 | 688 | |
| ... | ... | @@ -718,11 +719,14 @@ export function decodeMergeRequestDiffsJson( |
| 718 | 719 | } |
| 719 | 720 | const { from, to } = diffHeaderPaths(value); |
| 720 | 721 | 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}`)}`, | |
| 722 | 723 | ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), |
| 723 | 724 | ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), |
| 724 | 725 | ...(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 | ] | |
| 726 | 730 | : []), |
| 727 | 731 | `--- ${from}`, |
| 728 | 732 | `+++ ${to}`, |
| ... | ... | @@ -947,3 +951,82 @@ export function decodeOwnAwardIdJson( |
| 947 | 951 | } |
| 948 | 952 | return Result.succeed(null); |
| 949 | 953 | } |
| 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 | */ | |
| 960 | export 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 | ||
| 970 | const 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 | ||
| 1003 | const 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 | */ | |
| 1012 | export 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 | */ | |
| 6 | import * as Clock from "effect/Clock"; | |
| 7 | import * as DateTime from "effect/DateTime"; | |
| 8 | import * as Duration from "effect/Duration"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Semaphore from "effect/Semaphore"; | |
| 11 | import { | |
| 12 | PullRequestOperationError, | |
| 13 | type PullRequestFilesViewedResult, | |
| 14 | type PullRequestRef, | |
| 15 | type PullRequestSetFilesViewedInput, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | ||
| 18 | import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | |
| 19 | import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; | |
| 20 | import 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 | */ | |
| 27 | const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); | |
| 28 | const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); | |
| 29 | export 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 | */ | |
| 36 | export const MAX_FILE_REVISION_PATHS = 1_000; | |
| 37 | ||
| 38 | interface 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 | ||
| 47 | const 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 | ||
| 200 | export 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. | |
| 215 | export 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?: { |
| 755 | 755 | ); |
| 756 | 756 | |
| 757 | 757 | 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 | ), | |
| 759 | 763 | { |
| 760 | 764 | disableListenLog: true, |
| 761 | 765 | disableLogger: true, |
apps/server/src/server.ts+3| ... | ... | @@ -39,6 +39,7 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; |
| 39 | 39 | import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; |
| 40 | 40 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| 41 | 41 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 42 | import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; | |
| 42 | 43 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 43 | 44 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 44 | 45 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; |
| ... | ... | @@ -329,6 +330,8 @@ const RepositoryIdentityResolverLayerLive = Layer.effect( |
| 329 | 330 | |
| 330 | 331 | const PullRequestServiceLive = PullRequestService.layer.pipe( |
| 331 | 332 | 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), | |
| 332 | 335 | Layer.provide(PullRequestReadCache.layer), |
| 333 | 336 | Layer.provide(SourceControlProviderRegistryLayerLive), |
| 334 | 337 | Layer.provide(SourceControlRateLimit.layer), |
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+4-4| ... | ... | @@ -77,11 +77,11 @@ function encodeAzureDevOpsPathSegment(segment: string): string { |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | /** |
| 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. | |
| 83 | 83 | */ |
| 84 | export function azureDevOpsOrganizationBaseFromRestApiUrl( | |
| 84 | function azureDevOpsOrganizationBaseFromRestApiUrl( | |
| 85 | 85 | value: string | null | undefined, |
| 86 | 86 | ): string | null { |
| 87 | 87 | const rawUrl = trimOptionalString(value); |
apps/server/src/ws.ts+13-1| ... | ... | @@ -2751,6 +2751,18 @@ const makeWsRpcLayer = ( |
| 2751 | 2751 | withPullRequestViewer(input, pullRequests.diffFileContents(input)), |
| 2752 | 2752 | { "rpc.aggregate": "pull-requests" }, |
| 2753 | 2753 | ), |
| 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 | ), | |
| 2754 | 2766 | [WS_METHODS.pullRequestsRunAction]: (input) => |
| 2755 | 2767 | observeRpcEffect( |
| 2756 | 2768 | WS_METHODS.pullRequestsRunAction, |
| ... | ... | @@ -2824,7 +2836,7 @@ const makeWsRpcLayer = ( |
| 2824 | 2836 | // A reader asking for fresh host state also wants the thread badges it feeds to |
| 2825 | 2837 | // catch up, including a merged link the sweep would otherwise never revisit. |
| 2826 | 2838 | Effect.andThen( |
| 2827 | input.reference === undefined | |
| 2839 | input.reference === undefined || input.filesViewedOnly === true | |
| 2828 | 2840 | ? Effect.void |
| 2829 | 2841 | : resolvePullRequestSyncKey(input.reference).pipe( |
| 2830 | 2842 | Effect.flatMap((key) => |
apps/web/src/components/ChatMarkdown.tsx+2-1| ... | ... | @@ -53,6 +53,7 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li |
| 53 | 53 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 54 | 54 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 55 | 55 | import * as Cause from "effect/Cause"; |
| 56 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 56 | 57 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 57 | 58 | import React, { |
| 58 | 59 | Children, |
| ... | ... | @@ -2905,7 +2906,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2905 | 2906 | input: { |
| 2906 | 2907 | projectId: pullRequestProject.id, |
| 2907 | 2908 | repository: |
| 2908 | pullRequestProject.repositoryIdentity?.displayName ?? | |
| 2909 | sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? | |
| 2909 | 2910 | pullRequestCandidate.repository, |
| 2910 | 2911 | number: pullRequestCandidate.number, |
| 2911 | 2912 | }, |
apps/web/src/components/ChatView.tsx+4-1| ... | ... | @@ -76,6 +76,7 @@ import { |
| 76 | 76 | resolveProjectScripts, |
| 77 | 77 | } from "@t3tools/shared/projectScripts"; |
| 78 | 78 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 79 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | |
| 79 | 80 | import { truncate } from "@t3tools/shared/String"; |
| 80 | 81 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; |
| 81 | 82 | import { |
| ... | ... | @@ -4663,7 +4664,9 @@ export default function ChatView(props: ChatViewProps) { |
| 4663 | 4664 | const hasLinkedPullRequestDetail = activeThreadMetadata?.linkedPullRequest != null; |
| 4664 | 4665 | const linkedThreadPullRequest = |
| 4665 | 4666 | activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null; |
| 4666 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 4667 | const activeProjectRepository = sourceControlRepositorySelector( | |
| 4668 | activeProject?.repositoryIdentity, | |
| 4669 | ); | |
| 4667 | 4670 | const linkedThreadPullRequestKey = linkedThreadPullRequest |
| 4668 | 4671 | ? JSON.stringify([ |
| 4669 | 4672 | linkedThreadPullRequest.projectId, |
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+187-30| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | ChevronsUpDownIcon, |
| 18 | 18 | Columns2Icon, |
| 19 | 19 | FolderTreeIcon, |
| 20 | InfoIcon, | |
| 20 | 21 | MessageSquareIcon, |
| 21 | 22 | MessageSquareOffIcon, |
| 22 | 23 | Rows3Icon, |
| ... | ... | @@ -44,6 +45,7 @@ import { |
| 44 | 45 | resolveFileDiffPreviousPath, |
| 45 | 46 | type RenderablePatch, |
| 46 | 47 | } from "~/lib/diffRendering"; |
| 48 | import { APP_BASE_NAME } from "~/branding"; | |
| 47 | 49 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 48 | 50 | import { cn } from "~/lib/utils"; |
| 49 | 51 | import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; |
| ... | ... | @@ -63,6 +65,7 @@ import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; |
| 63 | 65 | import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; |
| 64 | 66 | import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; |
| 65 | 67 | import { Button } from "../ui/button"; |
| 68 | import { Checkbox } from "../ui/checkbox"; | |
| 66 | 69 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 67 | 70 | import { |
| 68 | 71 | DropdownMenu, |
| ... | ... | @@ -78,9 +81,11 @@ import { PullRequestReviewBar } from "./PullRequestReviewBar"; |
| 78 | 81 | import { |
| 79 | 82 | isFileDiffCollapsed, |
| 80 | 83 | isLineInFileDiff, |
| 84 | toggleFileDiffFoldForViewed, | |
| 81 | 85 | type DiffFoldOverride, |
| 82 | 86 | } from "./pullRequestDiff.logic"; |
| 83 | 87 | import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; |
| 88 | import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; | |
| 84 | 89 | import { |
| 85 | 90 | nextPendingReviewCommentId, |
| 86 | 91 | pullRequestReviewKey, |
| ... | ... | @@ -223,6 +228,8 @@ function PullRequestCodeTab({ |
| 223 | 228 | const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); |
| 224 | 229 | /** Set once the reader has asked for every file at once, until they pick a file apart again. */ |
| 225 | 230 | const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null); |
| 231 | const effectiveFoldOverride = | |
| 232 | foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"); | |
| 226 | 233 | const diffLayout = settings.diffLayout; |
| 227 | 234 | const updateClientSettings = useUpdateClientSettings(); |
| 228 | 235 | const [wordWrap, setWordWrap] = useState(settings.wordWrap); |
| ... | ... | @@ -331,13 +338,6 @@ function PullRequestCodeTab({ |
| 331 | 338 | input: { ...reference, ...(commit === null ? {} : { commit }) }, |
| 332 | 339 | }), |
| 333 | 340 | ); |
| 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]); | |
| 341 | 341 | const reviewKey = referenceKey; |
| 342 | 342 | const pendingComments = usePendingReviewComments(reference); |
| 343 | 343 | const addComment = usePullRequestReviewStore((store) => store.addComment); |
| ... | ... | @@ -410,6 +410,35 @@ function PullRequestCodeTab({ |
| 410 | 410 | ), |
| 411 | 411 | [parsedSlices], |
| 412 | 412 | ); |
| 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]); | |
| 413 | 442 | const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; |
| 414 | 443 | // What a slice withheld: the host declining to inline part of it, or a patch the viewer could |
| 415 | 444 | // not structure and so dropped. Neither says anything about there being more to fetch. |
| ... | ... | @@ -442,7 +471,9 @@ function PullRequestCodeTab({ |
| 442 | 471 | return placed; |
| 443 | 472 | }, [commit, detail.reviewThreads, files]); |
| 444 | 473 | |
| 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( | |
| 446 | 477 | () => |
| 447 | 478 | files.map((fileDiff) => { |
| 448 | 479 | const fileKey = buildFileDiffRenderKey(fileDiff); |
| ... | ... | @@ -484,27 +515,20 @@ function PullRequestCodeTab({ |
| 484 | 515 | groupAt(anchor.side, anchor.line).draft = true; |
| 485 | 516 | } |
| 486 | 517 | |
| 487 | const collapsed = isFileDiffCollapsed( | |
| 488 | fileKey, | |
| 489 | foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"), | |
| 490 | toggledFiles, | |
| 491 | ); | |
| 492 | ||
| 493 | 518 | const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ |
| 494 | 519 | side: toViewerSide(group.side), |
| 495 | 520 | lineNumber: group.line, |
| 496 | 521 | metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, |
| 497 | 522 | })); |
| 498 | 523 | return { |
| 499 | id: fileKey, | |
| 500 | type: "diff" as const, | |
| 524 | fileKey, | |
| 525 | path, | |
| 501 | 526 | fileDiff, |
| 502 | 527 | annotations, |
| 503 | collapsed, | |
| 504 | 528 | // The viewer re-renders an item only when its version changes, so everything the |
| 505 | 529 | // annotations show has to be part of it. |
| 506 | version: fnv1a32( | |
| 507 | `${collapsed ? "1" : "0"}:${annotations | |
| 530 | annotationsVersion: fnv1a32( | |
| 531 | annotations | |
| 508 | 532 | .map( |
| 509 | 533 | ({ side, lineNumber, metadata }) => |
| 510 | 534 | `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending |
| ... | ... | @@ -529,19 +553,37 @@ function PullRequestCodeTab({ |
| 529 | 553 | ) |
| 530 | 554 | .join(",")}`, |
| 531 | 555 | ) |
| 532 | .join("|")}`, | |
| 556 | .join("|"), | |
| 533 | 557 | ), |
| 534 | 558 | }; |
| 535 | 559 | }), |
| 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 | }), | |
| 536 | 581 | [ |
| 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, | |
| 545 | 587 | toggledFiles, |
| 546 | 588 | ], |
| 547 | 589 | ); |
| ... | ... | @@ -606,6 +648,19 @@ function PullRequestCodeTab({ |
| 606 | 648 | [], |
| 607 | 649 | ); |
| 608 | 650 | |
| 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 | ||
| 609 | 664 | const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey); |
| 610 | 665 | const revealFile = useCallback( |
| 611 | 666 | (path: string) => { |
| ... | ... | @@ -743,6 +798,14 @@ function PullRequestCodeTab({ |
| 743 | 798 | [toggleFile], |
| 744 | 799 | ); |
| 745 | 800 | |
| 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 | ||
| 746 | 809 | const renderHeaderMetadata = useCallback( |
| 747 | 810 | (item: CodeViewItem<ReviewAnnotationGroup>) => { |
| 748 | 811 | if (item.type !== "diff") return null; |
| ... | ... | @@ -752,17 +815,52 @@ function PullRequestCodeTab({ |
| 752 | 815 | additions += hunk.additionLines; |
| 753 | 816 | deletions += hunk.deletionLines; |
| 754 | 817 | } |
| 818 | const path = resolveFileDiffPath(item.fileDiff); | |
| 755 | 819 | if (additions === 0 && deletions === 0) { |
| 756 | const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff)); | |
| 820 | const withheld = omittedFileStats.get(path); | |
| 757 | 821 | if (withheld) ({ additions, deletions } = withheld); |
| 758 | 822 | } |
| 759 | return ( | |
| 823 | const stat = ( | |
| 760 | 824 | <PullRequestDiffStat |
| 761 | 825 | additions={additions} |
| 762 | 826 | deletions={deletions} |
| 763 | 827 | className="font-mono text-[11px]" |
| 764 | 828 | /> |
| 765 | 829 | ); |
| 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 | ); | |
| 766 | 864 | }, |
| 767 | 865 | [omittedFileStats], |
| 768 | 866 | ); |
| ... | ... | @@ -1092,6 +1190,62 @@ function PullRequestCodeTab({ |
| 1092 | 1190 | {files.length} {files.length === 1 ? "file" : "files"} |
| 1093 | 1191 | {nextCursor === null ? "" : "+"} |
| 1094 | 1192 | </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} | |
| 1095 | 1249 | {withheldContent ? ( |
| 1096 | 1250 | <Tooltip> |
| 1097 | 1251 | <TooltipTrigger render={<span className="flex shrink-0 items-center" />}> |
| ... | ... | @@ -1375,6 +1529,9 @@ function PullRequestCodeTab({ |
| 1375 | 1529 | if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { |
| 1376 | 1530 | return; |
| 1377 | 1531 | } |
| 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; | |
| 1378 | 1535 | if (node.hasAttribute("data-diffs-header")) { |
| 1379 | 1536 | const filePath = node.querySelector("[data-title]")?.textContent?.trim(); |
| 1380 | 1537 | if (filePath === undefined || filePath === "") return; |
apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts+32-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import type { FileDiffMetadata } from "@pierre/diffs"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic"; | |
| 4 | import { | |
| 5 | isFileDiffCollapsed, | |
| 6 | isLineInFileDiff, | |
| 7 | toggleFileDiffFoldForViewed, | |
| 8 | } from "./pullRequestDiff.logic"; | |
| 5 | 9 | |
| 6 | 10 | /** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */ |
| 7 | 11 | function fileWithHunks( |
| ... | ... | @@ -79,3 +83,30 @@ describe("isFileDiffCollapsed", () => { |
| 79 | 83 | expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); |
| 80 | 84 | }); |
| 81 | 85 | }); |
| 86 | ||
| 87 | describe("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( |
| 41 | 41 | const foldedByDefault = foldOverride === "folded"; |
| 42 | 42 | return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; |
| 43 | 43 | } |
| 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 | */ | |
| 52 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | countViewedFiles, | |
| 5 | isFileViewed, | |
| 6 | isStaleViewedState, | |
| 7 | revertFileViewedOverlay, | |
| 8 | settleFileViewedOverlay, | |
| 9 | toFileViewedBatch, | |
| 10 | toFileViewedStates, | |
| 11 | type FileViewedOverlay, | |
| 12 | } from "./pullRequestFilesViewed.logic"; | |
| 13 | ||
| 14 | const NO_OVERLAY: FileViewedOverlay = new Map(); | |
| 15 | const NOTHING_PENDING: ReadonlySet<string> = new Set(); | |
| 16 | const NOTHING_ANSWERED: ReadonlySet<string> = new Set(); | |
| 17 | ||
| 18 | const 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 | ||
| 27 | describe("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 | ||
| 50 | describe("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 | ||
| 59 | describe("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 | ||
| 121 | describe("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 | ||
| 137 | describe("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 @@ |
| 1 | import type { PullRequestFileViewedState, PullRequestFilesViewedResult } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | /** What the host last said about each file, by path. Absent means the host said nothing. */ | |
| 4 | export type FileViewedStates = ReadonlyMap<string, PullRequestFileViewedState>; | |
| 5 | ||
| 6 | /** Presses the host has not confirmed yet, by path. */ | |
| 7 | export type FileViewedOverlay = ReadonlyMap<string, boolean>; | |
| 8 | ||
| 9 | export 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 | */ | |
| 21 | function 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. */ | |
| 26 | export 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. */ | |
| 31 | export 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 | ||
| 40 | export 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 | */ | |
| 61 | export 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 | */ | |
| 82 | export 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. */ | |
| 96 | export 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 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | ProjectId, | |
| 4 | type PullRequestFilesViewedResult, | |
| 5 | type PullRequestRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 8 | import { act, StrictMode } from "react"; | |
| 9 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ | |
| 13 | host: { data: null as unknown, refresh: vi.fn() }, | |
| 14 | setFilesViewed: vi.fn(), | |
| 15 | toastAdd: vi.fn(), | |
| 16 | })); | |
| 17 | ||
| 18 | vi.mock("~/state/pullRequests", () => ({ | |
| 19 | pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} }, | |
| 20 | })); | |
| 21 | vi.mock("~/state/query", () => ({ | |
| 22 | useEnvironmentQuery: () => ({ | |
| 23 | data: host.data, | |
| 24 | error: null, | |
| 25 | isPending: false, | |
| 26 | isSuccess: true, | |
| 27 | refresh: host.refresh, | |
| 28 | }), | |
| 29 | })); | |
| 30 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed })); | |
| 31 | vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } })); | |
| 32 | ||
| 33 | import { | |
| 34 | usePullRequestFilesViewed, | |
| 35 | type PullRequestFilesViewedView, | |
| 36 | } from "./usePullRequestFilesViewed"; | |
| 37 | ||
| 38 | const environmentId = EnvironmentId.make("pr-files-viewed-audit"); | |
| 39 | const reference: PullRequestRef = { | |
| 40 | projectId: ProjectId.make("project-a"), | |
| 41 | repository: "acme/web", | |
| 42 | number: 42, | |
| 43 | }; | |
| 44 | const 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. */ | |
| 47 | function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { | |
| 48 | return { files: [{ path: "a.ts", state }], truncated: false }; | |
| 49 | } | |
| 50 | ||
| 51 | let renderer: ReactTestRenderer | null = null; | |
| 52 | ||
| 53 | function Probe(_props: { readonly view: PullRequestFilesViewedView }) { | |
| 54 | return null; | |
| 55 | } | |
| 56 | ||
| 57 | function Surface() { | |
| 58 | const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths }); | |
| 59 | return <Probe view={view} />; | |
| 60 | } | |
| 61 | ||
| 62 | function 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. */ | |
| 67 | async 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 | ||
| 78 | beforeEach(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 | ||
| 94 | afterEach(async () => { | |
| 95 | await act(async () => renderer?.unmount()); | |
| 96 | renderer = null; | |
| 97 | vi.useRealTimers(); | |
| 98 | vi.unstubAllGlobals(); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("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 | ||
| 133 | describe("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 @@ |
| 1 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | |
| 2 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | ||
| 5 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 6 | import { useEnvironmentQuery } from "~/state/query"; | |
| 7 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 8 | ||
| 9 | import { toastManager } from "../ui/toast"; | |
| 10 | import { | |
| 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 | */ | |
| 27 | const FLUSH_DELAY_MS = 400; | |
| 28 | ||
| 29 | const NO_OVERLAY: FileViewedOverlay = new Map(); | |
| 30 | ||
| 31 | export 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 | */ | |
| 58 | export 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", () => { |
| 209 | 209 | expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 }); |
| 210 | 210 | }); |
| 211 | 211 | }); |
| 212 | ||
| 213 | describe("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 @@ |
| 1 | 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | 2 | import type { FileDiffMetadata } from "@pierre/diffs/types"; |
| 3 | import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | |
| 3 | 4 | |
| 4 | 5 | const DIFF_THEME_NAMES = { |
| 5 | 6 | light: "pierre-light", |
| ... | ... | @@ -143,8 +144,18 @@ export function getRenderablePatch( |
| 143 | 144 | } |
| 144 | 145 | } |
| 145 | 146 | |
| 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 | */ | |
| 153 | function fileDiffPath(raw: string): string { | |
| 154 | return unquoteGitPatchPath(raw); | |
| 155 | } | |
| 156 | ||
| 146 | 157 | export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { |
| 147 | return fileDiff.name ?? fileDiff.prevName ?? ""; | |
| 158 | return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); | |
| 148 | 159 | } |
| 149 | 160 | |
| 150 | 161 | /** |
| ... | ... | @@ -152,7 +163,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { |
| 152 | 163 | * path, and the hosts that resolve a diff position against both sides need both names. |
| 153 | 164 | */ |
| 154 | 165 | export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { |
| 155 | return fileDiff.prevName ?? fileDiff.name ?? ""; | |
| 166 | return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); | |
| 156 | 167 | } |
| 157 | 168 | |
| 158 | 169 | export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { |
apps/web/src/lib/openPullRequestLink.test.ts+27| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | shouldOpenPullRequestExternally, |
| 12 | 12 | } from "./openPullRequestLink"; |
| 13 | 13 | import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; |
| 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 14 | 15 | |
| 15 | 16 | function repositoryIdentity( |
| 16 | 17 | provider: string, |
| ... | ... | @@ -498,6 +499,32 @@ describe("findProjectForChangeRequest", () => { |
| 498 | 499 | ).toBeUndefined(); |
| 499 | 500 | }); |
| 500 | 501 | |
| 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 | ||
| 501 | 528 | it("claims nothing for a lookalike host, which is what keeps a link a link", () => { |
| 502 | 529 | const projects = [ |
| 503 | 530 | project({ |
docs/README.md+1| ... | ... | @@ -36,6 +36,7 @@ source alone does not explain. Most code changes do not need an internal documen |
| 36 | 36 | - [Glossary](./internals/glossary.md) |
| 37 | 37 | - [Connection runtime](./internals/connection-runtime.md) |
| 38 | 38 | - [Providers](./internals/providers.md) |
| 39 | - [Pull request file revisions](./internals/pull-request-file-revisions.md) | |
| 39 | 40 | - [Model classification](./internals/model-manifest.md) |
| 40 | 41 | - [Remote environments](./internals/remote.md) |
| 41 | 42 | - [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 | ||
| 3 | On a host that keeps no viewed-file record of its own, this environment keeps the marks and asks | |
| 4 | the host what version each marked file is at, so that a push can report a cleared file as changed. | |
| 5 | That version travels through four layers, and a missing entry means something different at each | |
| 6 | one. No type distinguishes those meanings and no single file states the whole path, which is what | |
| 7 | this page is for. | |
| 8 | ||
| 9 | The failure is on record rather than hypothetical. The interface doc above | |
| 10 | [GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said | |
| 11 | the opposite of the code beneath it, because it described a missing path the way the layer below | |
| 12 | means it. Anything written against that reading would treat a version the host declined to give as | |
| 13 | a file the change request deleted, which reports every file a reader has cleared as changed across | |
| 14 | a project the token cannot see. | |
| 15 | ||
| 16 | ## An absence changes meaning at the provider boundary | |
| 17 | ||
| 18 | Below the boundary, in a patch parser or a response decoder, a missing path means this revision | |
| 19 | does not carry that file. Above it, in | |
| 20 | [`ProviderFileRevisions`](../../apps/server/src/pullRequest/PullRequestProvider.ts), a missing path | |
| 21 | means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its | |
| 22 | host looked at and has no version for arrives as the empty string, and absence is kept for what | |
| 23 | the provider never got to look at. Absence below the boundary does not survive it. | |
| 24 | ||
| 25 | What "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 | ||
| 36 | Those three facts stay with their providers. The contract states only that absence means the read | |
| 37 | could 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 | ||
| 41 | The empty string is an answer: the host looked, and the head has no version, which is where a | |
| 42 | deleted file sits. It compares equal to a mark stamped with it, so a mark taken against a deletion | |
| 43 | is cleared once and stays cleared. Everything below is the absence of an answer, and the three | |
| 44 | forms are not interchangeable. | |
| 45 | ||
| 46 | A null map, from the service's read of the whole scope, costs those marks their staleness. They | |
| 47 | still 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 | |
| 49 | gets no answer does not refuse the reader's tick: it stores the mark with no baseline, which is | |
| 50 | the third form below. | |
| 51 | ||
| 52 | A path absent from an answered map is the per-path case, and it is the one that must not be read | |
| 53 | as a deletion. [`HeldFileRevisions`](../../apps/server/src/pullRequest/pullRequestViewedFiles.ts) | |
| 54 | records what it has asked as well as what it heard, so an absent path keeps the last version given | |
| 55 | for it instead of losing one. | |
| 56 | ||
| 57 | A 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. | |
| 60 | Storing the empty string instead would report the file as changed the moment the host turned out | |
| 61 | to have a version after all. A mark with no baseline reads as cleared until the reader presses it | |
| 62 | again. | |
| 63 | ||
| 64 | Both read-side nulls collapse into that third one on the write path: whether the map was null or | |
| 65 | the 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. |
| 118 | 118 | an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or |
| 119 | 119 | PR creation from Git actions continue to use the project's environment. |
| 120 | 120 | |
| 121 | For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support | |
| 122 | reopening a declined pull request. | |
| 121 | For Azure DevOps, use the host website to change comments. Bitbucket does not support reopening a | |
| 122 | declined pull request. | |
| 123 | ||
| 124 | ### Mark files as viewed | |
| 125 | ||
| 126 | Tick a file off in the **Code** tab once you have read it and it collapses; the toolbar keeps a | |
| 127 | running count. A tick belongs to the pull request rather than to a commit, so scoping the tab to a | |
| 128 | single commit keeps them. A file pushed to after you cleared it comes back marked **Changed**. | |
| 129 | ||
| 130 | On GitHub these are GitHub's own viewed marks, so a review carries between T3 Code and github.com | |
| 131 | in either direction. Forgejo, GitLab, Bitbucket, and Azure DevOps expose no record T3 Code can read, so the | |
| 132 | server you are connected to keeps them instead: they follow you across the apps connected to that | |
| 133 | server, but the host's own site will not show them, and the count reads **viewed in T3 Code**. | |
| 134 | ||
| 135 | The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but | |
| 136 | does not show its diff, so marks are made and read on web and desktop. | |
| 123 | 137 | |
| 124 | 138 | ## Troubleshooting |
| 125 | 139 |
packages/client-runtime/src/state/pullRequestRouting.ts+10-3| ... | ... | @@ -32,10 +32,12 @@ const reads = new Set<string>([ |
| 32 | 32 | WS_METHODS.pullRequestsActivity, |
| 33 | 33 | WS_METHODS.pullRequestsThreadComments, |
| 34 | 34 | WS_METHODS.pullRequestsDiffFileContents, |
| 35 | WS_METHODS.pullRequestsFilesViewed, | |
| 35 | 36 | WS_METHODS.pullRequestsReviewerCandidates, |
| 36 | 37 | WS_METHODS.pullRequestsLabelCandidates, |
| 37 | 38 | ]); |
| 38 | 39 | const writes = new Set<string>([ |
| 40 | WS_METHODS.pullRequestsSetFilesViewed, | |
| 39 | 41 | WS_METHODS.pullRequestsRunAction, |
| 40 | 42 | WS_METHODS.pullRequestsUpdate, |
| 41 | 43 | WS_METHODS.pullRequestsComment, |
| ... | ... | @@ -182,7 +184,7 @@ export function createPullRequestRouter() { |
| 182 | 184 | targets, |
| 183 | 185 | ([target, reference]) => |
| 184 | 186 | invalidateTarget(registry, origin.target.environmentId, target, [ |
| 185 | input.reference === undefined ? {} : { reference }, | |
| 187 | { ...input, ...(input.reference === undefined ? {} : { reference }) }, | |
| 186 | 188 | ]), |
| 187 | 189 | { concurrency: 4, discard: true }, |
| 188 | 190 | ); |
| ... | ... | @@ -229,8 +231,13 @@ export function createPullRequestRouter() { |
| 229 | 231 | targets, |
| 230 | 232 | ([target, refs]) => |
| 231 | 233 | 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 ? [] : [{}]), | |
| 234 | 241 | ]), |
| 235 | 242 | { concurrency: 4, discard: true }, |
| 236 | 243 | ); |
packages/client-runtime/src/state/pullRequests.test.ts+78| ... | ... | @@ -236,6 +236,84 @@ for (const scenario of [ |
| 236 | 236 | ); |
| 237 | 237 | } |
| 238 | 238 | |
| 239 | for (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 | ||
| 239 | 317 | const TARGET = new PrimaryConnectionTarget({ |
| 240 | 318 | environmentId: EnvironmentId.make("environment-1"), |
| 241 | 319 | label: "Test environment", |
packages/client-runtime/src/state/pullRequests.ts+21| ... | ... | @@ -270,6 +270,27 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 270 | 270 | ]), |
| 271 | 271 | }, |
| 272 | 272 | }), |
| 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 | }), | |
| 273 | 294 | runAction: createEnvironmentRpcCommand(runtime, { |
| 274 | 295 | label: "environment-data:pull-requests:run-action", |
| 275 | 296 | tag: WS_METHODS.pullRequestsRunAction, |
packages/contracts/src/pullRequest.test.ts+67| ... | ... | @@ -4,9 +4,11 @@ import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | import { |
| 5 | 5 | PullRequestActionInput, |
| 6 | 6 | PullRequestCapabilities, |
| 7 | PullRequestFilesViewedResult, | |
| 7 | 8 | PullRequestListInput, |
| 8 | 9 | PullRequestListResult, |
| 9 | 10 | PullRequestReviewerRequestInput, |
| 11 | PullRequestSetFilesViewedInput, | |
| 10 | 12 | pullRequestHostOf, |
| 11 | 13 | resolvePullRequestAuthorFilter, |
| 12 | 14 | } from "./pullRequest.ts"; |
| ... | ... | @@ -15,6 +17,8 @@ const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult); |
| 15 | 17 | const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput); |
| 16 | 18 | const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput); |
| 17 | 19 | const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); |
| 20 | const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput); | |
| 21 | const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult); | |
| 18 | 22 | |
| 19 | 23 | const LIST_RESULT: PullRequestListResult = { |
| 20 | 24 | viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" }, |
| ... | ... | @@ -271,3 +275,66 @@ describe("naming the reader as the author to narrow by", () => { |
| 271 | 275 | expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me"); |
| 272 | 276 | }); |
| 273 | 277 | }); |
| 278 | ||
| 279 | describe("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({ |
| 371 | 371 | }); |
| 372 | 372 | export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; |
| 373 | 373 | |
| 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 | */ | |
| 379 | export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]); | |
| 380 | export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type; | |
| 381 | ||
| 374 | 382 | /** |
| 375 | 383 | * What a provider can actually do, so a surface can hide what is missing rather than offer an |
| 376 | 384 | * action that would fail. Every provider fills this in for itself; nothing is assumed. |
| ... | ... | @@ -407,6 +415,11 @@ export const PullRequestCapabilities = Schema.Struct({ |
| 407 | 415 | * what every server before this field was. |
| 408 | 416 | */ |
| 409 | 417 | 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), | |
| 410 | 423 | review: PullRequestReviewCapabilities, |
| 411 | 424 | reviewers: PullRequestReviewerCapabilities, |
| 412 | 425 | /** |
| ... | ... | @@ -784,6 +797,8 @@ export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; |
| 784 | 797 | */ |
| 785 | 798 | export const PullRequestInvalidateInput = Schema.Struct({ |
| 786 | 799 | reference: Schema.optional(PullRequestRef), |
| 800 | /** Refresh review progress across routed environments without discarding the patch. */ | |
| 801 | filesViewedOnly: Schema.optional(Schema.Boolean), | |
| 787 | 802 | }); |
| 788 | 803 | export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; |
| 789 | 804 | |
| ... | ... | @@ -953,6 +968,66 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ |
| 953 | 968 | }); |
| 954 | 969 | export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; |
| 955 | 970 | |
| 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 | */ | |
| 975 | const 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 | */ | |
| 980 | const 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 | */ | |
| 987 | export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]); | |
| 988 | export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; | |
| 989 | ||
| 990 | export const PullRequestFileViewed = Schema.Struct({ | |
| 991 | path: FilePath, | |
| 992 | state: PullRequestFileViewedState, | |
| 993 | }); | |
| 994 | export 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 | */ | |
| 1001 | export 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 | }); | |
| 1007 | export 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 | */ | |
| 1014 | const 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 | */ | |
| 1020 | export 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 | }); | |
| 1029 | export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type; | |
| 1030 | ||
| 956 | 1031 | export const PullRequestStackHead = Schema.Struct({ |
| 957 | 1032 | number: PositiveInt, |
| 958 | 1033 | headSha: TrimmedNonEmptyString, |
packages/contracts/src/rpc.ts+18| ... | ... | @@ -113,6 +113,7 @@ import { |
| 113 | 113 | PullRequestDetail, |
| 114 | 114 | PullRequestDiffFileContentsInput, |
| 115 | 115 | PullRequestDiffFileContentsResult, |
| 116 | PullRequestFilesViewedResult, | |
| 116 | 117 | PullRequestInvalidateInput, |
| 117 | 118 | PullRequestListInput, |
| 118 | 119 | PullRequestListResult, |
| ... | ... | @@ -131,6 +132,7 @@ import { |
| 131 | 132 | PullRequestReviewerRequestInput, |
| 132 | 133 | PullRequestLabelCandidateList, |
| 133 | 134 | PullRequestLabelChangeInput, |
| 135 | PullRequestSetFilesViewedInput, | |
| 134 | 136 | PullRequestSubmitReviewInput, |
| 135 | 137 | PullRequestThreadCommentsInput, |
| 136 | 138 | PullRequestThreadCommentsResult, |
| ... | ... | @@ -396,6 +398,8 @@ export const WS_METHODS = { |
| 396 | 398 | pullRequestsActivity: "pullRequests.activity", |
| 397 | 399 | pullRequestsThreadComments: "pullRequests.threadComments", |
| 398 | 400 | pullRequestsDiffFileContents: "pullRequests.diffFileContents", |
| 401 | pullRequestsFilesViewed: "pullRequests.filesViewed", | |
| 402 | pullRequestsSetFilesViewed: "pullRequests.setFilesViewed", | |
| 399 | 403 | pullRequestsRunAction: "pullRequests.runAction", |
| 400 | 404 | pullRequestsUpdate: "pullRequests.update", |
| 401 | 405 | pullRequestsComment: "pullRequests.comment", |
| ... | ... | @@ -751,6 +755,18 @@ const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFi |
| 751 | 755 | error: PullRequestRpcError, |
| 752 | 756 | }); |
| 753 | 757 | |
| 758 | const WsPullRequestsFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsFilesViewed, { | |
| 759 | payload: PullRequestRef, | |
| 760 | success: PullRequestFilesViewedResult, | |
| 761 | error: PullRequestRpcError, | |
| 762 | }); | |
| 763 | ||
| 764 | const WsPullRequestsSetFilesViewedRpc = Rpc.make(WS_METHODS.pullRequestsSetFilesViewed, { | |
| 765 | payload: PullRequestSetFilesViewedInput, | |
| 766 | success: Schema.Void, | |
| 767 | error: PullRequestRpcError, | |
| 768 | }); | |
| 769 | ||
| 754 | 770 | const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { |
| 755 | 771 | payload: PullRequestActionInput, |
| 756 | 772 | success: Schema.Void, |
| ... | ... | @@ -1405,6 +1421,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1405 | 1421 | WsPullRequestsActivityRpc, |
| 1406 | 1422 | WsPullRequestsThreadCommentsRpc, |
| 1407 | 1423 | WsPullRequestsDiffFileContentsRpc, |
| 1424 | WsPullRequestsFilesViewedRpc, | |
| 1425 | WsPullRequestsSetFilesViewedRpc, | |
| 1408 | 1426 | WsPullRequestsRunActionRpc, |
| 1409 | 1427 | WsPullRequestsUpdateRpc, |
| 1410 | 1428 | WsPullRequestsCommentRpc, |
packages/shared/package.json+4| ... | ... | @@ -338,6 +338,10 @@ |
| 338 | 338 | "./cliRelease": { |
| 339 | 339 | "types": "./src/cliRelease.ts", |
| 340 | 340 | "import": "./src/cliRelease.ts" |
| 341 | }, | |
| 342 | "./gitPatchPath": { | |
| 343 | "types": "./src/gitPatchPath.ts", | |
| 344 | "import": "./src/gitPatchPath.ts" | |
| 341 | 345 | } |
| 342 | 346 | }, |
| 343 | 347 | "scripts": { |
packages/shared/src/git.test.ts+32| ... | ... | @@ -50,6 +50,38 @@ describe("normalizeGitRemoteUrl", () => { |
| 50 | 50 | "bitbucket.org/workspace/repo", |
| 51 | 51 | ); |
| 52 | 52 | }); |
| 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 | }); | |
| 53 | 85 | }); |
| 54 | 86 | |
| 55 | 87 | describe("parseOriginUrlFromGitConfig", () => { |
packages/shared/src/git.ts+29-8| ... | ... | @@ -108,6 +108,25 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { |
| 108 | 108 | return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); |
| 109 | 109 | } |
| 110 | 110 | |
| 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 | */ | |
| 120 | function 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 | ||
| 111 | 130 | /** |
| 112 | 131 | * Normalize a git remote URL into a stable comparison key. |
| 113 | 132 | */ |
| ... | ... | @@ -121,12 +140,12 @@ export function normalizeGitRemoteUrl(value: string): string { |
| 121 | 140 | if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { |
| 122 | 141 | try { |
| 123 | 142 | 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 | ); | |
| 130 | 149 | } |
| 131 | 150 | } catch { |
| 132 | 151 | return normalized; |
| ... | ... | @@ -136,8 +155,10 @@ export function normalizeGitRemoteUrl(value: string): string { |
| 136 | 155 | const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( |
| 137 | 156 | normalized, |
| 138 | 157 | ); |
| 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}`; | |
| 141 | 162 | } |
| 142 | 163 | |
| 143 | 164 | return normalized; |
packages/shared/src/gitPatchPath.test.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { quoteGitPatchPath, unquoteGitPatchPath } from "./gitPatchPath.ts"; | |
| 4 | ||
| 5 | const BELL = "\u0007"; | |
| 6 | const UNIT_SEPARATOR = "\u001f"; | |
| 7 | const DELETE = "\u007f"; | |
| 8 | ||
| 9 | describe("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 | ||
| 32 | describe("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 | ||
| 70 | describe("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 | ||
| 86 | describe("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 | */ | |
| 15 | const 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. */ | |
| 28 | const 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 | ||
| 40 | const QUOTE = '"'; | |
| 41 | const DELETE_CHARACTER = 0x7f; | |
| 42 | const LOWEST_PRINTABLE = 0x20; | |
| 43 | const BACKSLASH = 0x5c; | |
| 44 | ||
| 45 | const utf8 = new TextEncoder(); | |
| 46 | const 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 | */ | |
| 54 | export 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 | */ | |
| 83 | function 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 | */ | |
| 135 | export 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 |
| 208 | 208 | }); |
| 209 | 209 | expect(selector).toBe("group/subgroup/service"); |
| 210 | 210 | }); |
| 211 | ||
| 212 | it("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 | ||
| 221 | it("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: |
| 512 | 512 | '@opencode-ai/sdk': |
| 513 | 513 | specifier: ^1.3.15 |
| 514 | 514 | version: 1.15.13 |
| 515 | diff: | |
| 516 | specifier: 8.0.3 | |
| 517 | version: 8.0.3 | |
| 515 | 518 | effect: |
| 516 | 519 | specifier: 4.0.0-rc.112 |
| 517 | 520 | version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) |