authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-12 23:58:36+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 22:58:36+02:00
logb28f9bf0a1bd562623c027c5ed80b5ca50395b28
tree10832ae430e9f98c5ef5d303d9e4f9a4b997c21b
parentd0b8d6306b4686528913948b21c8a9eeda01645a
signature Signed by PGP key B5690EEEBB952194

feat(web): pull request surfaces — filters & qualifiers, all-server listing, update branch, reactions, in-place editing, smarter diffs (#6039)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

86 files changed, 11929 insertions(+), 622 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+3
...@@ -58,10 +58,13 @@ export const RPC_REQUIRED_SCOPES = {...@@ -58,10 +58,13 @@ export const RPC_REQUIRED_SCOPES = {
58 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,58 [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
59 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,59 [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
60 [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,60 [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
61 [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
61 [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,62 [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
63 [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope,
62 [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope,64 [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope,
63 [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,65 [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,
64 [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,66 [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,
67 [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope,
65 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only68 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
66 // client pressing refresh must not be told it may not look again.69 // client pressing refresh must not be told it may not look again.
67 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,70 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
apps/server/src/git/GitManager.test.ts+54
...@@ -9,8 +9,10 @@ import * as Duration from "effect/Duration";...@@ -9,8 +9,10 @@ import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
12import * as Logger from "effect/Logger";
12import * as Option from "effect/Option";13import * as Option from "effect/Option";
13import * as PlatformError from "effect/PlatformError";14import * as PlatformError from "effect/PlatformError";
15import * as References from "effect/References";
14import * as Scope from "effect/Scope";16import * as Scope from "effect/Scope";
15import { ChildProcessSpawner } from "effect/unstable/process";17import { ChildProcessSpawner } from "effect/unstable/process";
16import { expect } from "vite-plus/test";18import { expect } from "vite-plus/test";
...@@ -1386,6 +1388,58 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1386,6 +1388,58 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1386 }),1388 }),
1387 );1389 );
13881390
1391 it.effect("status logs actionable provider detail without exposing the upstream cause", () =>
1392 Effect.gen(function* () {
1393 const repoDir = yield* makeTempDir("t3code-git-manager-");
1394 yield* initRepo(repoDir);
1395 yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]);
1396 const remoteDir = yield* createBareRemote();
1397 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1398 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]);
1399
1400 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";
1401 const { manager } = yield* makeManager({
1402 ghScenario: {
1403 failWith: new GitHubCli.GitHubCliRateLimitError({
1404 command: "gh",
1405 cwd: repoDir,
1406 cause: new Error(upstreamCause),
1407 }),
1408 },
1409 });
1410 const logs: Array<{ message: string; annotations: Record<string, unknown> }> = [];
1411 const logger = Logger.make<unknown, void>(({ fiber, message }) => {
1412 logs.push({
1413 message: String(message),
1414 annotations: { ...fiber.getRef(References.CurrentLogAnnotations) },
1415 });
1416 });
1417
1418 const status = yield* manager
1419 .status({ cwd: repoDir })
1420 .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
1421
1422 expect(status.pr).toBeNull();
1423 const warning = logs.find((entry) => entry.message.includes("PR lookup failed"));
1424 expect(warning?.annotations).toMatchObject({
1425 operation: "lookupStatusPr",
1426 branch: "feature/status-rate-limited",
1427 errorTag: "SourceControlProviderError",
1428 provider: "github",
1429 providerOperation: "listChangeRequests",
1430 providerCommand: "gh",
1431 errorDetail:
1432 "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time.",
1433 });
1434 const loggedText = [
1435 warning?.message ?? "",
1436 ...Object.values(warning?.annotations ?? {}).map(String),
1437 ].join("\n");
1438 expect(loggedText).not.toContain(upstreamCause);
1439 expect(loggedText).not.toContain("secret-value");
1440 }),
1441 );
1442
1389 it.effect("status keeps the last known PR when a later lookup fails", () =>1443 it.effect("status keeps the last known PR when a later lookup fails", () =>
1390 Effect.gen(function* () {1444 Effect.gen(function* () {
1391 const repoDir = yield* makeTempDir("t3code-git-manager-");1445 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+11
...@@ -12,6 +12,7 @@ import * as Option from "effect/Option";...@@ -12,6 +12,7 @@ import * as Option from "effect/Option";
12import * as Order from "effect/Order";12import * as Order from "effect/Order";
13import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import * as Ref from "effect/Ref";14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
15import {16import {
16 GitActionProgressEvent,17 GitActionProgressEvent,
17 GitActionProgressPhase,18 GitActionProgressPhase,
...@@ -28,6 +29,7 @@ import {...@@ -28,6 +29,7 @@ import {
28 type VcsStatusRemoteResult,29 type VcsStatusRemoteResult,
29 VcsStatusResult,30 VcsStatusResult,
30 ModelSelection,31 ModelSelection,
32 SourceControlProviderError,
31 type SourceControlWritingStyleSettings,33 type SourceControlWritingStyleSettings,
32} from "@t3tools/contracts";34} from "@t3tools/contracts";
33import {35import {
...@@ -113,6 +115,7 @@ const PR_LOOKUP_CACHE_TTL = Duration.minutes(2);...@@ -113,6 +115,7 @@ const PR_LOOKUP_CACHE_TTL = Duration.minutes(2);
113const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);115const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
114const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);116const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
115const PR_LOOKUP_CACHE_CAPACITY = 2_048;117const PR_LOOKUP_CACHE_CAPACITY = 2_048;
118const isSourceControlProviderError = Schema.is(SourceControlProviderError);
116119
117/**120/**
118 * How long a failed PR lookup is cached, given the number of consecutive121 * How long a failed PR lookup is cached, given the number of consecutive
...@@ -1048,6 +1051,14 @@ export const make = Effect.gen(function* () {...@@ -1048,6 +1051,14 @@ export const make = Effect.gen(function* () {
1048 typeof error === "object" && error !== null && "_tag" in error1051 typeof error === "object" && error !== null && "_tag" in error
1049 ? String(error._tag)1052 ? String(error._tag)
1050 : typeof error,1053 : typeof error,
1054 ...(isSourceControlProviderError(error)
1055 ? {
1056 provider: error.provider,
1057 providerOperation: error.operation,
1058 providerCommand: error.command ?? "unknown",
1059 errorDetail: error.detail,
1060 }
1061 : {}),
1051 }),1062 }),
1052 Effect.andThen(resolveBranchHeadContext(cwd, details)),1063 Effect.andThen(resolveBranchHeadContext(cwd, details)),
1053 Effect.map((headContext) =>1064 Effect.map((headContext) =>
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+106
...@@ -342,7 +342,40 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {...@@ -342,7 +342,40 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
342 }),342 }),
343 );343 );
344344
345 it.effect("stores the squash choice with an auto-completion, as a merge now does", () =>
346 Effect.gen(function* () {
347 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
348 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
349
350 yield* cli.runPullRequestAction({
351 cwd: "/w",
352 number: 42,
353 action: "enable-auto-merge",
354 mergeMethod: "squash",
355 });
356
357 expect(argsOfCall(0)).toEqual([
358 "repos",
359 "pr",
360 "update",
361 "--detect",
362 "true",
363 "--id",
364 "42",
365 "--auto-complete",
366 "true",
367 "--squash",
368 "true",
369 "--only-show-errors",
370 "--output",
371 "json",
372 ]);
373 }),
374 );
375
345 it.effect.each([376 it.effect.each([
377 { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] },
378 { action: "disable-auto-merge", expected: ["--auto-complete", "false"] },
346 { action: "draft", expected: ["--draft", "true"] },379 { action: "draft", expected: ["--draft", "true"] },
347 { action: "ready", expected: ["--draft", "false"] },380 { action: "ready", expected: ["--draft", "false"] },
348 { action: "close", expected: ["--status", "abandoned"] },381 { action: "close", expected: ["--status", "abandoned"] },
...@@ -370,6 +403,79 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {...@@ -370,6 +403,79 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
370 }),403 }),
371 );404 );
372405
406 it.effect.each([
407 { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] },
408 {
409 name: "a description",
410 rewrite: { body: "Why the page changed" },
411 expected: ["--description=Why the page changed"],
412 },
413 {
414 name: "both",
415 rewrite: { title: "Add the page", body: "Why the page changed" },
416 expected: ["--title=Add the page", "--description=Why the page changed"],
417 },
418 ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) =>
419 Effect.gen(function* () {
420 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
421 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
422
423 yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite });
424
425 expect(argsOfCall(0)).toEqual([
426 "repos",
427 "pr",
428 "update",
429 "--detect",
430 "true",
431 "--id",
432 "42",
433 ...expected,
434 "--only-show-errors",
435 "--output",
436 "json",
437 ]);
438 }),
439 );
440
441 it.effect("sends a description that starts with a dash as one value, not as a flag", () =>
442 Effect.gen(function* () {
443 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
444 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
445
446 yield* cli.updatePullRequest({
447 cwd: "/w",
448 number: 42,
449 body: "- rewrote the page\n- kept the rest",
450 });
451
452 // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag,
453 // and the whole text stays together where `--description` would otherwise take several.
454 expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest");
455 }),
456 );
457
458 it.effect("rewrites through the provider, which says it takes one", () =>
459 Effect.gen(function* () {
460 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
461 const provider = yield* AzureDevOpsPullRequestProvider.make;
462
463 // False for a remark because nothing here can post one, so there is none to rewrite.
464 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false });
465 assert.isDefined(provider.updateChangeRequest);
466 yield* provider.updateChangeRequest({
467 cwd: "/w",
468 repository: "web",
469 host: "dev.azure.com",
470 number: 42,
471 title: "Add the page",
472 });
473
474 expect(argsOfCall(0)).toContain("--title=Add the page");
475 expect(argsOfCall(0)).not.toContain("--description");
476 }),
477 );
478
373 it.effect("reads the conversation through the REST API, pinned to a version", () =>479 it.effect("reads the conversation through the REST API, pinned to a version", () =>
374 Effect.gen(function* () {480 Effect.gen(function* () {
375 mockedExecute.mockReturnValueOnce(481 mockedExecute.mockReturnValueOnce(
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+40
...@@ -159,6 +159,14 @@ export class AzureDevOpsPullRequestCli extends Context.Service<...@@ -159,6 +159,14 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
159 readonly mergeMethod?: PullRequestMergeMethod;159 readonly mergeMethod?: PullRequestMergeMethod;
160 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;160 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
161161
162 /** Rewrites the pull request's own words, through the same command that moves it. */
163 readonly updatePullRequest: (input: {
164 readonly cwd: string;
165 readonly number: number;
166 readonly title?: string | undefined;
167 readonly body?: string | undefined;
168 }) => Effect.Effect<void, AzureDevOpsPullRequestCliError>;
169
162 /**170 /**
163 * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole171 * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole
164 * of what Azure offers here: it adds and removes named identities, and has no counterpart that172 * of what Azure offers here: it adds and removes named identities, and has no counterpart that
...@@ -212,12 +220,21 @@ function actionArgs(...@@ -212,12 +220,21 @@ function actionArgs(
212 switch (action) {220 switch (action) {
213 case "merge":221 case "merge":
214 return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];222 return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"];
223 // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes
224 // it once its policies pass. The squash choice is stored with it, as it is for a merge now.
225 case "enable-auto-merge":
226 return ["--auto-complete", "true", "--squash", mergeMethod === "squash" ? "true" : "false"];
227 case "disable-auto-merge":
228 return ["--auto-complete", "false"];
215 case "ready":229 case "ready":
216 return ["--draft", "false"];230 return ["--draft", "false"];
217 case "draft":231 case "draft":
218 return ["--draft", "true"];232 return ["--draft", "true"];
219 case "close":233 case "close":
220 return ["--status", "abandoned"];234 return ["--status", "abandoned"];
235 // Never reached: this host does not declare the action, so nothing offers it.
236 case "update-branch":
237 return [];
221 case "reopen":238 case "reopen":
222 return ["--status", "active"];239 return ["--status", "active"];
223 }240 }
...@@ -481,6 +498,29 @@ export const make = Effect.gen(function* () {...@@ -481,6 +498,29 @@ export const make = Effect.gen(function* () {
481 ],498 ],
482 })499 })
483 .pipe(Effect.asVoid),500 .pipe(Effect.asVoid),
501
502 updatePullRequest: (input) =>
503 azure
504 .execute({
505 cwd: input.cwd,
506 args: [
507 "repos",
508 "pr",
509 "update",
510 ...detectArgs,
511 "--id",
512 String(input.number),
513 // One argument rather than a flag and a value beside it: a description usually opens
514 // with a bullet, and az reads a dash in the next argv slot as a flag of its own.
515 // `--description` also takes several strings, and this keeps the whole text as one.
516 ...(input.title === undefined ? [] : [`--title=${input.title}`]),
517 ...(input.body === undefined ? [] : [`--description=${input.body}`]),
518 "--only-show-errors",
519 "--output",
520 "json",
521 ],
522 })
523 .pipe(Effect.asVoid),
484 });524 });
485});525});
486526
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts+9-1
...@@ -9,7 +9,15 @@ describe("azure devops viewer permissions", () => {...@@ -9,7 +9,15 @@ describe("azure devops viewer permissions", () => {
9 // and an unknown permission is granted rather than guessed away. Azure refuses the ones it9 // and an unknown permission is granted rather than guessed away. Azure refuses the ones it
10 // will not allow, at the moment they are taken, in words this could not have written.10 // will not allow, at the moment they are taken, in words this could not have written.
11 expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({11 expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({
12 actions: ["merge", "ready", "draft", "close", "reopen"],12 actions: [
13 "merge",
14 "ready",
15 "draft",
16 "close",
17 "reopen",
18 "enable-auto-merge",
19 "disable-auto-merge",
20 ],
13 // False because the host itself cannot post one, not because this viewer may not.21 // False because the host itself cannot post one, not because this viewer may not.
14 comment: false,22 comment: false,
15 resolve: false,23 resolve: false,
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+27-1
...@@ -18,11 +18,20 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -18,11 +18,20 @@ const CAPABILITIES: PullRequestCapabilities = {
18 // Reading a conversation is a plain REST read, but posting one is not something this can18 // Reading a conversation is a plain REST read, but posting one is not something this can
19 // claim without having run it, so the composer stays hidden.19 // claim without having run it, so the composer stays hidden.
20 comment: false,20 comment: false,
21 actions: ["merge", "ready", "draft", "close", "reopen"],21 actions: [
22 "merge",
23 "ready",
24 "draft",
25 "close",
26 "reopen",
27 "enable-auto-merge",
28 "disable-auto-merge",
29 ],
22 // Azure squashes as a completion option; it has no rebase strategy of its own.30 // Azure squashes as a completion option; it has no rebase strategy of its own.
23 mergeMethods: ["merge", "squash"],31 mergeMethods: ["merge", "squash"],
24 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.32 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
25 search: false,33 search: false,
34 reactions: false,
26 // With no patch to show there are no lines to write against, so nothing here is offered.35 // With no patch to show there are no lines to write against, so nothing here is offered.
27 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },36 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
28 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`37 // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
...@@ -30,6 +39,10 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -30,6 +39,10 @@ const CAPABILITIES: PullRequestCapabilities = {
30 // different service with its own permissions. So the page takes a name here rather than being39 // different service with its own permissions. So the page takes a name here rather than being
31 // handed a menu built out of a guess.40 // handed a menu built out of a guess.
32 reviewers: { request: true, listCandidates: false },41 reviewers: { request: true, listCandidates: false },
42 // A new title and description travel on the same `az repos pr update` that moves a pull request.
43 // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
44 // Azure DevOps at all, so there is nothing here it could rewrite either.
45 edit: { changeRequest: true, comment: false },
33};46};
3447
35/**48/**
...@@ -153,6 +166,7 @@ export const make = Effect.gen(function* () {...@@ -153,6 +166,7 @@ export const make = Effect.gen(function* () {
153 checks: [],166 checks: [],
154 mergeCapabilities: { merge: true, squash: true, rebase: false },167 mergeCapabilities: { merge: true, squash: true, rebase: false },
155 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,168 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
169 autoMergeEnabled: pullRequest.autoMergeEnabled,
156 }),170 }),
157 ),171 ),
158 ),172 ),
...@@ -206,6 +220,16 @@ export const make = Effect.gen(function* () {...@@ -206,6 +220,16 @@ export const make = Effect.gen(function* () {
206 })220 })
207 .pipe(Effect.mapError(fail("runAction"))),221 .pipe(Effect.mapError(fail("runAction"))),
208222
223 updateChangeRequest: (input) =>
224 cli
225 .updatePullRequest({
226 cwd: input.cwd,
227 number: input.number,
228 title: input.title,
229 body: input.body,
230 })
231 .pipe(Effect.mapError(fail("updateChangeRequest"))),
232
209 // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the233 // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the
210 // list without it.234 // list without it.
211 listReviewerCandidates: () =>235 listReviewerCandidates: () =>
...@@ -240,6 +264,8 @@ export const make = Effect.gen(function* () {...@@ -240,6 +264,8 @@ export const make = Effect.gen(function* () {
240 replyToThread: () => unsupported("replyToThread"),264 replyToThread: () => unsupported("replyToThread"),
241265
242 setThreadResolution: () => unsupported("setThreadResolution"),266 setThreadResolution: () => unsupported("setThreadResolution"),
267
268 setReaction: () => unsupported("setReaction"),
243 };269 };
244270
245 return provider;271 return provider;
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts+68
...@@ -510,6 +510,74 @@ layer("BitbucketPullRequestApi.layer", (it) => {...@@ -510,6 +510,74 @@ layer("BitbucketPullRequestApi.layer", (it) => {
510 }),510 }),
511 );511 );
512512
513 it.effect("rewrites a title alone, without touching anything else", () =>
514 Effect.gen(function* () {
515 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
516 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
517
518 yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" });
519
520 const call = callAt(0);
521 expect(call.method).toBe("PUT");
522 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
523 // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was.
524 // @effect-diagnostics-next-line preferSchemaOverJson:off
525 expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" });
526 }),
527 );
528
529 it.effect("leaves out the half of the pull request it was not asked about", () =>
530 Effect.gen(function* () {
531 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
532 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
533
534 yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." });
535
536 // @effect-diagnostics-next-line preferSchemaOverJson:off
537 expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." });
538 }),
539 );
540
541 it.effect("writes both fields when both were rewritten", () =>
542 Effect.gen(function* () {
543 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
544 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
545
546 yield* api.updateChangeRequest({
547 repository: "acme/web",
548 number: 7,
549 title: "A new title",
550 body: "New body.",
551 });
552
553 // @effect-diagnostics-next-line preferSchemaOverJson:off
554 expect(JSON.parse(callAt(0).body ?? "")).toEqual({
555 title: "A new title",
556 description: "New body.",
557 });
558 }),
559 );
560
561 it.effect("rewrites a comment where it stands, whichever kind it is", () =>
562 Effect.gen(function* () {
563 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
564 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
565
566 yield* api.updateComment({
567 repository: "acme/web",
568 number: 7,
569 commentId: "10",
570 body: "Edited.",
571 });
572
573 expect(callAt(0)).toMatchObject({
574 method: "PUT",
575 url: "/repositories/acme/web/pullrequests/7/comments/10",
576 body: '{"content":{"raw":"Edited."}}',
577 });
578 }),
579 );
580
513 it.effect("fails the read when Bitbucket answers with something unreadable", () =>581 it.effect("fails the read when Bitbucket answers with something unreadable", () =>
514 Effect.gen(function* () {582 Effect.gen(function* () {
515 mockedRequest.mockReturnValueOnce(583 mockedRequest.mockReturnValueOnce(
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+47
...@@ -222,12 +222,26 @@ export class BitbucketPullRequestApi extends Context.Service<...@@ -222,12 +222,26 @@ export class BitbucketPullRequestApi extends Context.Service<
222 readonly mergeMethod?: PullRequestMergeMethod;222 readonly mergeMethod?: PullRequestMergeMethod;
223 }) => Effect.Effect<void, BitbucketPullRequestApiError>;223 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
224224
225 readonly updateChangeRequest: (input: {
226 readonly repository: string;
227 readonly number: number;
228 readonly title?: string | undefined;
229 readonly body?: string | undefined;
230 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
231
225 readonly comment: (input: {232 readonly comment: (input: {
226 readonly repository: string;233 readonly repository: string;
227 readonly number: number;234 readonly number: number;
228 readonly body: string;235 readonly body: string;
229 }) => Effect.Effect<void, BitbucketPullRequestApiError>;236 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
230237
238 readonly updateComment: (input: {
239 readonly repository: string;
240 readonly number: number;
241 readonly commentId: string;
242 readonly body: string;
243 }) => Effect.Effect<void, BitbucketPullRequestApiError>;
244
231 readonly submitReview: (input: {245 readonly submitReview: (input: {
232 readonly repository: string;246 readonly repository: string;
233 readonly number: number;247 readonly number: number;
...@@ -701,6 +715,24 @@ export const make = Effect.gen(function* () {...@@ -701,6 +715,24 @@ export const make = Effect.gen(function* () {
701 .pipe(Effect.asVoid);715 .pipe(Effect.asVoid);
702 }),716 }),
703717
718 updateChangeRequest: (input) =>
719 withRepository(input.repository, (path) =>
720 // Only the words this call rewrites travel in the body: as `setReviewerRequest` above
721 // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it
722 // was — sending `reviewers` back here would overwrite a change another user made to it
723 // between this call being issued and the request landing.
724 bitbucket
725 .request({
726 method: "PUT",
727 url: `${path}/pullrequests/${input.number}`,
728 body: JSON.stringify({
729 ...(input.title === undefined ? {} : { title: input.title }),
730 ...(input.body === undefined ? {} : { description: input.body }),
731 }),
732 })
733 .pipe(Effect.asVoid),
734 ),
735
704 comment: (input) =>736 comment: (input) =>
705 withRepository(input.repository, (path) =>737 withRepository(input.repository, (path) =>
706 bitbucket738 bitbucket
...@@ -713,6 +745,21 @@ export const make = Effect.gen(function* () {...@@ -713,6 +745,21 @@ export const make = Effect.gen(function* () {
713 .pipe(Effect.asVoid),745 .pipe(Effect.asVoid),
714 ),746 ),
715747
748 updateComment: (input) =>
749 withRepository(input.repository, (path) =>
750 bitbucket
751 .request({
752 // Bitbucket keeps a pull request's remarks and its line comments in the one
753 // collection, so this endpoint rewrites either kind.
754 method: "PUT",
755 url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent(
756 input.commentId,
757 )}`,
758 body: JSON.stringify({ content: { raw: input.body } }),
759 })
760 .pipe(Effect.asVoid),
761 ),
762
716 submitReview: (input) =>763 submitReview: (input) =>
717 withRepository(input.repository, (path) =>764 withRepository(input.repository, (path) =>
718 Effect.gen(function* () {765 Effect.gen(function* () {
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+35
...@@ -19,6 +19,9 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -19,6 +19,9 @@ const CAPABILITIES: PullRequestCapabilities = {
19 actions: ["merge", "close"],19 actions: ["merge", "close"],
20 mergeMethods: ["merge", "squash", "rebase"],20 mergeMethods: ["merge", "squash", "rebase"],
21 search: true,21 search: true,
22 // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is
23 // read and none is offered.
24 reactions: false,
22 review: {25 review: {
23 inlineComment: true,26 inlineComment: true,
24 reply: true,27 reply: true,
...@@ -26,6 +29,7 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -26,6 +29,7 @@ const CAPABILITIES: PullRequestCapabilities = {
26 verdicts: ["comment", "approve", "request-changes"],29 verdicts: ["comment", "approve", "request-changes"],
27 },30 },
28 reviewers: { request: true, listCandidates: true },31 reviewers: { request: true, listCandidates: true },
32 edit: { changeRequest: true, comment: true },
29};33};
3034
31/**35/**
...@@ -249,11 +253,31 @@ export const make = Effect.gen(function* () {...@@ -249,11 +253,31 @@ export const make = Effect.gen(function* () {
249 })253 })
250 .pipe(Effect.mapError(fail("runAction"))),254 .pipe(Effect.mapError(fail("runAction"))),
251255
256 updateChangeRequest: (input) =>
257 api
258 .updateChangeRequest({
259 repository: input.repository,
260 number: input.number,
261 title: input.title,
262 body: input.body,
263 })
264 .pipe(Effect.mapError(fail("updateChangeRequest"))),
265
252 comment: (input) =>266 comment: (input) =>
253 api267 api
254 .comment({ repository: input.repository, number: input.number, body: input.body })268 .comment({ repository: input.repository, number: input.number, body: input.body })
255 .pipe(Effect.mapError(fail("comment"))),269 .pipe(Effect.mapError(fail("comment"))),
256270
271 updateComment: (input) =>
272 api
273 .updateComment({
274 repository: input.repository,
275 number: input.number,
276 commentId: input.commentId,
277 body: input.body,
278 })
279 .pipe(Effect.mapError(fail("updateComment"))),
280
257 submitReview: (input) =>281 submitReview: (input) =>
258 api282 api
259 .submitReview({283 .submitReview({
...@@ -275,6 +299,17 @@ export const make = Effect.gen(function* () {...@@ -275,6 +299,17 @@ export const make = Effect.gen(function* () {
275 })299 })
276 .pipe(Effect.mapError(fail("replyToThread"))),300 .pipe(Effect.mapError(fail("replyToThread"))),
277301
302 // Never called: `capabilities.reactions` is false, and the service refuses without it.
303 setReaction: () =>
304 Effect.fail(
305 new PullRequestProviderError({
306 provider: "bitbucket",
307 operation: "setReaction",
308 reason: "failed",
309 detail: "Bitbucket does not support reactions.",
310 }),
311 ),
312
278 setThreadResolution: (input) =>313 setThreadResolution: (input) =>
279 api314 api
280 .setCommentResolution({315 .setCommentResolution({
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+629-1
...@@ -5,6 +5,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -5,6 +5,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
55
6import * as GitHubCli from "../sourceControl/GitHubCli.ts";6import * as GitHubCli from "../sourceControl/GitHubCli.ts";
7import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";7import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
8import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
89
9const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();10const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
1011
...@@ -551,6 +552,206 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -551,6 +552,206 @@ layer("GitHubPullRequestCli.layer", (it) => {
551 }),552 }),
552 );553 );
553554
555 it.effect("carries the further narrowings into the search as qualifiers", () =>
556 Effect.gen(function* () {
557 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
558 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
559
560 yield* cli.listPullRequests({
561 cwd: "/w",
562 repository: "acme/web",
563 host: "github.com",
564 state: "open",
565 involvement: "all",
566 viewer: "bilal",
567 limit: 10,
568 filters: {
569 draft: "hide",
570 review: "changes-requested",
571 checks: "failing",
572 labels: [["needs design"], ['quo"te']],
573 excludedLabels: ["wip"],
574 author: "octocat",
575 },
576 });
577
578 // Quotes around anything a reader typed, and the one character that could end a quoted
579 // value early dropped rather than escaped.
580 expect(searchOfCall(0)).toBe(
581 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' +
582 "review:changes_requested status:failure sort:updated-desc",
583 );
584 }),
585 );
586
587 it.effect('resolves an author filter of "me" to the viewer, not the literal word', () =>
588 Effect.gen(function* () {
589 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
590 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
591
592 yield* cli.listPullRequests({
593 cwd: "/w",
594 repository: "acme/web",
595 host: "github.com",
596 state: "open",
597 involvement: "all",
598 viewer: "bilal",
599 limit: 10,
600 filters: { author: "me" },
601 });
602
603 expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc');
604 }),
605 );
606
607 it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () =>
608 Effect.gen(function* () {
609 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
610 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
611
612 yield* cli.listPullRequests({
613 cwd: "/w",
614 repository: "acme/web",
615 host: "github.com",
616 state: "open",
617 involvement: "all",
618 viewer: "bilal",
619 limit: 10,
620 filters: { labels: [["size:S", "size:XS"], ["bug"]] },
621 });
622
623 // One qualifier satisfied by either size, and a second one that must hold as well.
624 expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc');
625 expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc');
626 }),
627 );
628
629 it.effect(
630 "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows",
631 () =>
632 Effect.gen(function* () {
633 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
634 mockedExecute.mockReturnValueOnce(
635 Effect.succeed(
636 output(
637 pullRequests(2, 1, (number) => ({
638 statusCheckRollup:
639 number === 1
640 ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }]
641 : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }],
642 })),
643 ),
644 ),
645 );
646 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
647
648 const batch = yield* cli.listPullRequests({
649 cwd: "/w",
650 repository: "acme/web",
651 host: "github.com",
652 state: "open",
653 involvement: "all",
654 viewer: "bilal",
655 limit: 10,
656 filters: { checks: "passing" },
657 });
658
659 // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is
660 // now a filter the fallback judges itself, the same as `draft`: an empty search answer
661 // under it is still ambiguous, and the row picked out afterwards is the one whose own
662 // `checksState` reads "passing".
663 expect(searchOfCall(1)).toBeUndefined();
664 assert.deepStrictEqual(
665 batch.items.map((item) => item.number),
666 [1],
667 );
668 }),
669 );
670
671 it.effect("fails a checks filter for a row whose checks are still pending", () =>
672 Effect.gen(function* () {
673 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
674 mockedExecute.mockReturnValueOnce(
675 Effect.succeed(
676 output(
677 pullRequests(1, 1, () => ({
678 statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }],
679 })),
680 ),
681 ),
682 );
683 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
684
685 const batch = yield* cli.listPullRequests({
686 cwd: "/w",
687 repository: "acme/web",
688 host: "github.com",
689 state: "open",
690 involvement: "all",
691 viewer: "bilal",
692 limit: 10,
693 filters: { checks: "passing" },
694 });
695
696 // Pending equals neither "passing" nor "failing", so it satisfies neither filter value —
697 // the same row would also be dropped by `checks: "failing"`.
698 assert.deepStrictEqual(batch.items, []);
699 }),
700 );
701
702 it.effect(
703 "falls back for a repository the index does not cover even under a judgeable filter",
704 () =>
705 Effect.gen(function* () {
706 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
707 mockedExecute.mockReturnValueOnce(
708 Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))),
709 );
710 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
711
712 const batch = yield* cli.listPullRequests({
713 cwd: "/w",
714 repository: "acme/web",
715 host: "github.com",
716 state: "open",
717 involvement: "all",
718 viewer: "bilal",
719 limit: 10,
720 filters: { draft: "hide" },
721 });
722
723 // `draft` is a filter the fallback can judge over its own rows just as search judges it,
724 // so an empty search answer under it alone is still ambiguous between "nothing matches"
725 // and "this repository is not indexed" — and the fallback applies the filter itself,
726 // keeping only the non-draft row.
727 expect(searchOfCall(1)).toBeUndefined();
728 expect(batch.items.map((item) => item.number)).toEqual([2]);
729 }),
730 );
731
732 it.effect("carries the further narrowings into a batched search", () =>
733 Effect.gen(function* () {
734 mockedExecute.mockReturnValue(Effect.succeed(searchPage([])));
735 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
736
737 yield* cli.searchPullRequests({
738 cwd: "/w",
739 host: "github.com",
740 repositories: ["acme/web"],
741 state: "open",
742 involvement: "all",
743 viewer: "bilal",
744 limit: 10,
745 filters: { draft: "only", review: "none", labels: [["bug"]] },
746 });
747
748 assert.strictEqual(
749 searchQueryOfCall(0),
750 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web',
751 );
752 }),
753 );
754
554 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>755 it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () =>
555 Effect.gen(function* () {756 Effect.gen(function* () {
556 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));757 mockedExecute.mockReturnValue(Effect.succeed(output("[]")));
...@@ -819,6 +1020,40 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -819,6 +1020,40 @@ layer("GitHubPullRequestCli.layer", (it) => {
819 }),1020 }),
820 );1021 );
8211022
1023 it.effect("updates a stale branch with a merge commit unless asked to rebase", () =>
1024 Effect.gen(function* () {
1025 mockedExecute.mockReturnValue(Effect.succeed(output("")));
1026 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1027
1028 yield* cli.runPullRequestAction({
1029 cwd: "/w",
1030 repository: "acme/web",
1031 host: "github.com",
1032 number: 7,
1033 action: "update-branch",
1034 });
1035 // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise.
1036 expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]);
1037
1038 yield* cli.runPullRequestAction({
1039 cwd: "/w",
1040 repository: "acme/web",
1041 host: "github.com",
1042 number: 7,
1043 action: "update-branch",
1044 updateMethod: "rebase",
1045 });
1046 expect(callAt(1).args).toEqual([
1047 "pr",
1048 "update-branch",
1049 "7",
1050 "--repo",
1051 "github.com/acme/web",
1052 "--rebase",
1053 ]);
1054 }),
1055 );
1056
822 it.effect("merges with the strategy it was asked for", () =>1057 it.effect("merges with the strategy it was asked for", () =>
823 Effect.gen(function* () {1058 Effect.gen(function* () {
824 mockedExecute.mockReturnValue(Effect.succeed(output("")));1059 mockedExecute.mockReturnValue(Effect.succeed(output("")));
...@@ -844,6 +1079,74 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -844,6 +1079,74 @@ layer("GitHubPullRequestCli.layer", (it) => {
844 }),1079 }),
845 );1080 );
8461081
1082 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
1083 Effect.gen(function* () {
1084 mockedExecute.mockReturnValue(Effect.succeed(output("")));
1085 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1086
1087 yield* cli.runPullRequestAction({
1088 cwd: "/w",
1089 repository: "acme/web",
1090 host: "github.com",
1091 number: 7,
1092 action: "enable-auto-merge",
1093 mergeMethod: "squash",
1094 });
1095 expect(callAt(0).args).toEqual([
1096 "pr",
1097 "merge",
1098 "7",
1099 "--repo",
1100 "github.com/acme/web",
1101 "--auto",
1102 "--squash",
1103 ]);
1104
1105 // No strategy asked for is GitHub's own default, exactly as it is for a merge now.
1106 yield* cli.runPullRequestAction({
1107 cwd: "/w",
1108 repository: "acme/web",
1109 host: "github.com",
1110 number: 7,
1111 action: "enable-auto-merge",
1112 });
1113 expect(callAt(1).args).toEqual([
1114 "pr",
1115 "merge",
1116 "7",
1117 "--repo",
1118 "github.com/acme/web",
1119 "--auto",
1120 "--merge",
1121 ]);
1122 }),
1123 );
1124
1125 it.effect("takes auto-merge back off without naming a strategy", () =>
1126 Effect.gen(function* () {
1127 mockedExecute.mockReturnValue(Effect.succeed(output("")));
1128 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1129
1130 yield* cli.runPullRequestAction({
1131 cwd: "/w",
1132 repository: "acme/web",
1133 host: "github.com",
1134 number: 7,
1135 action: "disable-auto-merge",
1136 mergeMethod: "squash",
1137 });
1138
1139 expect(callAt(0).args).toEqual([
1140 "pr",
1141 "merge",
1142 "7",
1143 "--repo",
1144 "github.com/acme/web",
1145 "--disable-auto",
1146 ]);
1147 }),
1148 );
1149
847 it.effect("returns a pull request to draft by undoing ready", () =>1150 it.effect("returns a pull request to draft by undoing ready", () =>
848 Effect.gen(function* () {1151 Effect.gen(function* () {
849 mockedExecute.mockReturnValue(Effect.succeed(output("")));1152 mockedExecute.mockReturnValue(Effect.succeed(output("")));
...@@ -1406,6 +1709,279 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1406,6 +1709,279 @@ layer("GitHubPullRequestCli.layer", (it) => {
1406 }),1709 }),
1407 );1710 );
14081711
1712 it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () =>
1713 Effect.gen(function* () {
1714 mockedExecute.mockReturnValueOnce(
1715 Effect.succeed(
1716 output(
1717 // @effect-diagnostics-next-line preferSchemaOverJson:off
1718 JSON.stringify({
1719 data: {
1720 repository: { pullRequest: { id: "PR_kwDOA" } },
1721 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
1722 },
1723 }),
1724 ),
1725 ),
1726 );
1727 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1728 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1729
1730 yield* cli.setReaction({
1731 cwd: "/w",
1732 repository: "acme/web",
1733 host: "github.com",
1734 number: 7,
1735 subjectId: "IC_1",
1736 content: "heart",
1737 reacted: true,
1738 });
1739
1740 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1741 const scopeCheck = callAt(0).args;
1742 expect(scopeCheck).toContain("owner=acme");
1743 expect(scopeCheck).toContain("name=web");
1744 expect(scopeCheck).toContain("number=7");
1745 expect(scopeCheck).toContain("subjectId=IC_1");
1746 // @effect-diagnostics-next-line preferSchemaOverJson:off
1747 const request = JSON.parse(callAt(1).stdin ?? "") as {
1748 query: string;
1749 variables: Record<string, string>;
1750 };
1751 expect(request.query).toContain("addReaction(");
1752 expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" });
1753 }),
1754 );
1755
1756 it.effect("refuses a given subject that belongs to a different pull request", () =>
1757 Effect.gen(function* () {
1758 mockedExecute.mockReturnValueOnce(
1759 Effect.succeed(
1760 output(
1761 // @effect-diagnostics-next-line preferSchemaOverJson:off
1762 JSON.stringify({
1763 data: {
1764 repository: { pullRequest: { id: "PR_thisOne" } },
1765 // A comment on pull request #99 of a different repository, named as though it
1766 // belonged to #7 here.
1767 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
1768 },
1769 }),
1770 ),
1771 ),
1772 );
1773 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1774
1775 const error = yield* Effect.flip(
1776 cli.setReaction({
1777 cwd: "/w",
1778 repository: "acme/web",
1779 host: "github.com",
1780 number: 7,
1781 subjectId: "IC_99",
1782 content: "heart",
1783 reacted: true,
1784 }),
1785 );
1786
1787 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
1788 // Refused before any mutation was sent.
1789 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1790 }),
1791 );
1792
1793 it.effect("looks up the pull request's own node id when no subject was given", () =>
1794 Effect.gen(function* () {
1795 mockedExecute.mockReturnValueOnce(
1796 Effect.succeed(
1797 output(
1798 // @effect-diagnostics-next-line preferSchemaOverJson:off
1799 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
1800 ),
1801 ),
1802 );
1803 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1804 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1805
1806 yield* cli.setReaction({
1807 cwd: "/w",
1808 repository: "acme/web",
1809 host: "github.com",
1810 number: 7,
1811 content: "rocket",
1812 reacted: true,
1813 });
1814
1815 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1816 const lookup = callAt(0).args;
1817 expect(lookup).toContain("owner=acme");
1818 expect(lookup).toContain("name=web");
1819 expect(lookup).toContain("number=7");
1820 // @effect-diagnostics-next-line preferSchemaOverJson:off
1821 const request = JSON.parse(callAt(1).stdin ?? "") as {
1822 query: string;
1823 variables: Record<string, string>;
1824 };
1825 expect(request.query).toContain("addReaction(");
1826 expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" });
1827 }),
1828 );
1829
1830 it.effect("takes a reaction back through the remove mutation", () =>
1831 Effect.gen(function* () {
1832 mockedExecute.mockReturnValueOnce(
1833 Effect.succeed(
1834 output(
1835 // @effect-diagnostics-next-line preferSchemaOverJson:off
1836 JSON.stringify({
1837 data: {
1838 repository: { pullRequest: { id: "PR_kwDOA" } },
1839 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
1840 },
1841 }),
1842 ),
1843 ),
1844 );
1845 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1846 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1847
1848 yield* cli.setReaction({
1849 cwd: "/w",
1850 repository: "acme/web",
1851 host: "github.com",
1852 number: 7,
1853 subjectId: "IC_1",
1854 content: "heart",
1855 reacted: false,
1856 });
1857
1858 // @effect-diagnostics-next-line preferSchemaOverJson:off
1859 const request = JSON.parse(callAt(1).stdin ?? "") as { query: string };
1860 expect(request.query).toContain("removeReaction(");
1861 }),
1862 );
1863
1864 it.effect("rewrites only the words a request named", () =>
1865 Effect.gen(function* () {
1866 mockedExecute.mockReturnValue(
1867 Effect.succeed(
1868 output(
1869 // @effect-diagnostics-next-line preferSchemaOverJson:off
1870 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
1871 ),
1872 ),
1873 );
1874 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1875 const rewrite = (fields: { readonly title?: string; readonly body?: string }) =>
1876 cli.updatePullRequest({
1877 cwd: "/w",
1878 repository: "acme/web",
1879 host: "github.com",
1880 number: 7,
1881 ...fields,
1882 });
1883
1884 yield* rewrite({ title: "A better title" });
1885 yield* rewrite({ body: "A better description." });
1886 yield* rewrite({ title: "Both", body: "at once." });
1887
1888 // Each rewrite looks the pull request's node id up first, then mutates.
1889 const variablesAt = (index: number) =>
1890 (JSON.parse(callAt(index).stdin ?? "") as { variables: Record<string, string> }).variables;
1891 expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" });
1892 expect(variablesAt(3)).toEqual({
1893 pullRequestId: "PR_kwDOA",
1894 body: "A better description.",
1895 });
1896 expect(variablesAt(5)).toEqual({
1897 pullRequestId: "PR_kwDOA",
1898 title: "Both",
1899 body: "at once.",
1900 });
1901 // The reader's own words, so they travel the way every other body does.
1902 expect(callAt(5).args.join(" ")).not.toContain("at once.");
1903 }),
1904 );
1905
1906 it.effect("rewrites a remark through the mutation its kind needs", () =>
1907 Effect.gen(function* () {
1908 mockedExecute.mockReturnValue(
1909 Effect.succeed(
1910 output(
1911 // @effect-diagnostics-next-line preferSchemaOverJson:off
1912 JSON.stringify({
1913 data: {
1914 repository: { pullRequest: { id: "PR_kwDOA" } },
1915 node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } },
1916 },
1917 }),
1918 ),
1919 ),
1920 );
1921 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1922 const rewrite = (kind: "issue-comment" | "review-comment") =>
1923 cli.updateComment({
1924 cwd: "/w",
1925 repository: "acme/web",
1926 host: "github.com",
1927 number: 7,
1928 commentId: "IC_1",
1929 kind,
1930 body: "Reworded.",
1931 });
1932
1933 yield* rewrite("issue-comment");
1934 yield* rewrite("review-comment");
1935
1936 const parse = (index: number) =>
1937 JSON.parse(callAt(index).stdin ?? "") as {
1938 query: string;
1939 variables: Record<string, string>;
1940 };
1941 expect(callAt(0).args).toContain("subjectId=IC_1");
1942 expect(parse(1).query).toContain("updateIssueComment(");
1943 expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." });
1944 expect(parse(3).query).toContain("updatePullRequestReviewComment(");
1945 expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." });
1946 }),
1947 );
1948
1949 it.effect("refuses a comment that belongs to a different pull request", () =>
1950 Effect.gen(function* () {
1951 mockedExecute.mockReturnValueOnce(
1952 Effect.succeed(
1953 output(
1954 // @effect-diagnostics-next-line preferSchemaOverJson:off
1955 JSON.stringify({
1956 data: {
1957 repository: { pullRequest: { id: "PR_thisOne" } },
1958 node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } },
1959 },
1960 }),
1961 ),
1962 ),
1963 );
1964 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1965
1966 const error = yield* Effect.flip(
1967 cli.updateComment({
1968 cwd: "/w",
1969 repository: "acme/web",
1970 host: "github.com",
1971 number: 7,
1972 commentId: "IC_99",
1973 kind: "issue-comment",
1974 body: "Reworded.",
1975 }),
1976 );
1977
1978 assert.strictEqual(error._tag, "GitHubSubjectScopeError");
1979 expect(error.message).toContain("updateComment");
1980 // Refused before any mutation was sent.
1981 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1982 }),
1983 );
1984
1409 it.effect("fails the read when gh returns something unreadable", () =>1985 it.effect("fails the read when gh returns something unreadable", () =>
1410 Effect.gen(function* () {1986 Effect.gen(function* () {
1411 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));1987 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));
...@@ -1472,7 +2048,7 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1472,7 +2048,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
1472 expect(detail.body).toBe("Core body");2048 expect(detail.body).toBe("Core body");
1473 expect(activity.author?.login).toBe("octocat");2049 expect(activity.author?.login).toBe("octocat");
1474 expect(callAt(0).args.at(-1)).toBe(2050 expect(callAt(0).args.at(-1)).toBe(
1475 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,body,changedFiles,closedAt,statusCheckRollup",2051 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest",
1476 );2052 );
1477 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");2053 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
1478 }),2054 }),
...@@ -1634,6 +2210,58 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -1634,6 +2210,58 @@ layer("GitHubPullRequestCli.layer", (it) => {
1634 }),2210 }),
1635 );2211 );
16362212
2213 it.effect("sends the base comparison's variables as gh flags, not as bare words", () =>
2214 Effect.gen(function* () {
2215 mockedExecute.mockReturnValue(
2216 Effect.succeed(
2217 output(
2218 // @effect-diagnostics-next-line preferSchemaOverJson:off
2219 JSON.stringify({
2220 data: {
2221 repository: {
2222 pullRequest: {
2223 viewerCanUpdateBranch: true,
2224 baseRef: { compare: { behindBy: 4 } },
2225 },
2226 },
2227 },
2228 }),
2229 ),
2230 ),
2231 );
2232 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2233
2234 const comparison = yield* cli.getPullRequestBaseComparison({
2235 cwd: "/w",
2236 repository: "acme/web",
2237 host: "github.com",
2238 number: 7,
2239 headRef: "fork:feat/page",
2240 });
2241
2242 // The tuples are flattened straight into argv, so a variable without its flag is a
2243 // positional argument gh refuses outright.
2244 const args = callAt(0).args;
2245 expect(args).toEqual([
2246 "api",
2247 "graphql",
2248 "--hostname",
2249 "github.com",
2250 "-f",
2251 "owner=acme",
2252 "-f",
2253 "name=web",
2254 "-F",
2255 "number=7",
2256 "-f",
2257 "headRef=fork:feat/page",
2258 "-f",
2259 `query=${BASE_COMPARISON_GRAPHQL_QUERY}`,
2260 ]);
2261 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });
2262 }),
2263 );
2264
1637 it.effect("reads the viewer's role off the same call as the merge settings", () =>2265 it.effect("reads the viewer's role off the same call as the merge settings", () =>
1638 Effect.gen(function* () {2266 Effect.gen(function* () {
1639 mockedExecute.mockReturnValue(2267 mockedExecute.mockReturnValue(
apps/server/src/pullRequest/GitHubPullRequestCli.ts+410-15
...@@ -3,22 +3,29 @@ import * as Effect from "effect/Effect";...@@ -3,22 +3,29 @@ import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import type {6import {
7 PullRequestAction,7 resolvePullRequestAuthorFilter,
8 PullRequestActor,8 type PullRequestAction,
9 PullRequestInvolvement,9 type PullRequestActor,
10 PullRequestListState,10 type PullRequestInvolvement,
11 PullRequestMergeMethod,11 type PullRequestListFilters,
12 PullRequestReviewCommentDraft,12 type PullRequestListState,
13 PullRequestReviewVerdict,13 type PullRequestMergeMethod,
14 PullRequestReviewerCandidateList,14 type PullRequestOmittedFileStat,
15 PullRequestReviewerKind,15 type PullRequestReaction,
16 PullRequestThreadComment,16 type PullRequestReactionContent,
17 type PullRequestReviewCommentDraft,
18 type PullRequestReviewVerdict,
19 type PullRequestReviewerCandidateList,
20 type PullRequestReviewerKind,
21 type PullRequestThreadComment,
22 type PullRequestUpdateMethod,
17} from "@t3tools/contracts";23} from "@t3tools/contracts";
1824
19import * as GitHubCli from "../sourceControl/GitHubCli.ts";25import * as GitHubCli from "../sourceControl/GitHubCli.ts";
20import {26import {
21 ACTOR_AVATARS_GRAPHQL_QUERY,27 ACTOR_AVATARS_GRAPHQL_QUERY,
28 ADD_REACTION_GRAPHQL_MUTATION,
22 buildReviewSubmissionJson,29 buildReviewSubmissionJson,
23 buildReviewerRequestJson,30 buildReviewerRequestJson,
24 decodeActorAvatarsJson,31 decodeActorAvatarsJson,
...@@ -26,10 +33,13 @@ import {...@@ -26,10 +33,13 @@ import {
26 decodePullRequestDetailJson,33 decodePullRequestDetailJson,
27 decodePullRequestFilesJson,34 decodePullRequestFilesJson,
28 decodePullRequestListJson,35 decodePullRequestListJson,
36 decodePullRequestNodeIdJson,
29 decodePullRequestSearchJson,37 decodePullRequestSearchJson,
30 decodePullRequestStatsJson,38 decodePullRequestStatsJson,
39 decodeReactionSubjectScopeJson,
31 decodeRepositoryAccessJson,40 decodeRepositoryAccessJson,
32 decodeReviewerCandidatesJson,41 decodeReviewerCandidatesJson,
42 decodeReviewDismissalsJson,
33 decodeReviewThreadCommentsJson,43 decodeReviewThreadCommentsJson,
34 decodeReviewThreadsJson,44 decodeReviewThreadsJson,
35 buildPullRequestStatsGraphQlQuery,45 buildPullRequestStatsGraphQlQuery,
...@@ -37,18 +47,29 @@ import {...@@ -37,18 +47,29 @@ import {
37 pullRequestSearchGraphQlQuery,47 pullRequestSearchGraphQlQuery,
38 PULL_REQUEST_SEARCH_MAX_ROWS,48 PULL_REQUEST_SEARCH_MAX_ROWS,
39 PULL_REQUEST_ACTIVITY_JSON_FIELDS,49 PULL_REQUEST_ACTIVITY_JSON_FIELDS,
50 BASE_COMPARISON_GRAPHQL_QUERY,
51 decodeBaseComparisonJson,
40 PULL_REQUEST_DETAIL_JSON_FIELDS,52 PULL_REQUEST_DETAIL_JSON_FIELDS,
41 PULL_REQUEST_LIST_JSON_FIELDS,53 PULL_REQUEST_LIST_JSON_FIELDS,
54 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
55 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
56 REMOVE_REACTION_GRAPHQL_MUTATION,
57 gitHubReactionContent,
42 REPOSITORY_ACCESS_JSON_FIELDS,58 REPOSITORY_ACCESS_JSON_FIELDS,
43 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,59 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
44 REVIEWER_CANDIDATES_GRAPHQL_QUERY,60 REVIEWER_CANDIDATES_GRAPHQL_QUERY,
45 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,61 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
62 REVIEW_DISMISSALS_GRAPHQL_QUERY,
46 REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,63 REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
47 REVIEW_THREADS_GRAPHQL_QUERY,64 REVIEW_THREADS_GRAPHQL_QUERY,
48 reviewThreadConversation,65 reviewThreadConversation,
49 UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,66 UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
67 UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION,
68 UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
69 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
50 VIEWER_PERMISSIONS_GRAPHQL_QUERY,70 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
51 decodeViewerPermissionsJson,71 decodeViewerPermissionsJson,
72 type GitHubBaseComparison,
52 type GitHubPullRequestDetail,73 type GitHubPullRequestDetail,
53 type GitHubPullRequestActivity,74 type GitHubPullRequestActivity,
54 type GitHubPullRequestListItem,75 type GitHubPullRequestListItem,
...@@ -199,6 +220,24 @@ export class GitHubRepositorySelectorError extends Schema.TaggedErrorClass<GitHu...@@ -199,6 +220,24 @@ export class GitHubRepositorySelectorError extends Schema.TaggedErrorClass<GitHu
199 }220 }
200}221}
201222
223/** Not a decode failure: the reader named a subject this pull request never handed out. */
224export class GitHubSubjectScopeError extends Schema.TaggedErrorClass<GitHubSubjectScopeError>()(
225 "GitHubSubjectScopeError",
226 {
227 command: Schema.Literal("gh"),
228 cwd: Schema.String,
229 operation: Schema.String,
230 },
231) {
232 get detail(): string {
233 return "The named subject did not belong to the named pull request.";
234 }
235
236 override get message(): string {
237 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
238 }
239}
240
202export type GitHubPullRequestCliError =241export type GitHubPullRequestCliError =
203 | GitHubCli.GitHubCliError242 | GitHubCli.GitHubCliError
204 | GitHubPullRequestReadError243 | GitHubPullRequestReadError
...@@ -207,6 +246,7 @@ export type GitHubPullRequestCliError =...@@ -207,6 +246,7 @@ export type GitHubPullRequestCliError =
207 | GitHubDiffRevisionsUnavailableError246 | GitHubDiffRevisionsUnavailableError
208 | GitHubDiffFileContentsUnavailableError247 | GitHubDiffFileContentsUnavailableError
209 | GitHubRepositorySelectorError248 | GitHubRepositorySelectorError
249 | GitHubSubjectScopeError
210 | GitHubViewerLoginUnavailableError;250 | GitHubViewerLoginUnavailableError;
211251
212/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */252/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
...@@ -270,6 +310,8 @@ export interface GitHubPullRequestDiffSlice {...@@ -270,6 +310,8 @@ export interface GitHubPullRequestDiffSlice {
270 readonly truncated: boolean;310 readonly truncated: boolean;
271 /** Where the next slice starts, or null once the patch is whole. */311 /** Where the next slice starts, or null once the patch is whole. */
272 readonly nextCursor: string | null;312 readonly nextCursor: string | null;
313 /** GitHub's own counts for the files whose hunks it withheld from this slice. */
314 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
273}315}
274316
275export class GitHubPullRequestCli extends Context.Service<317export class GitHubPullRequestCli extends Context.Service<
...@@ -291,6 +333,8 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -291,6 +333,8 @@ export class GitHubPullRequestCli extends Context.Service<
291 readonly query?: string | undefined;333 readonly query?: string | undefined;
292 /** Where to carry on from, as a `updated:` qualifier on the same search. */334 /** Where to carry on from, as a `updated:` qualifier on the same search. */
293 readonly cursor?: ProviderListCursor | undefined;335 readonly cursor?: ProviderListCursor | undefined;
336 /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */
337 readonly filters?: PullRequestListFilters | undefined;
294 }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError>;338 }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestCliError>;
295339
296 /**340 /**
...@@ -309,6 +353,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -309,6 +353,7 @@ export class GitHubPullRequestCli extends Context.Service<
309 readonly limit: number;353 readonly limit: number;
310 readonly query?: string | undefined;354 readonly query?: string | undefined;
311 readonly cursor?: ProviderListCursor | undefined;355 readonly cursor?: ProviderListCursor | undefined;
356 readonly filters?: PullRequestListFilters | undefined;
312 }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestCliError>;357 }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestCliError>;
313358
314 /** The line counts the search leaves out, for rows already on the page. */359 /** The line counts the search leaves out, for rows already on the page. */
...@@ -328,6 +373,20 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -328,6 +373,20 @@ export class GitHubPullRequestCli extends Context.Service<
328 readonly number: number;373 readonly number: number;
329 }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>;374 }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>;
330375
376 /**
377 * How far the branch trails its base, and whether this viewer may update it. Its own read
378 * because the comparison needs the head ref the detail answers with — a fork's branch is not
379 * addressable in the base repository by name alone.
380 */
381 readonly getPullRequestBaseComparison: (input: {
382 readonly cwd: string;
383 readonly repository: string;
384 readonly host: string;
385 readonly number: number;
386 /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */
387 readonly headRef: string;
388 }) => Effect.Effect<GitHubBaseComparison, GitHubPullRequestCliError>;
389
331 readonly getPullRequestActivity: (input: {390 readonly getPullRequestActivity: (input: {
332 readonly cwd: string;391 readonly cwd: string;
333 readonly repository: string;392 readonly repository: string;
...@@ -418,6 +477,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -418,6 +477,7 @@ export class GitHubPullRequestCli extends Context.Service<
418 readonly number: number;477 readonly number: number;
419 readonly action: PullRequestAction;478 readonly action: PullRequestAction;
420 readonly mergeMethod?: PullRequestMergeMethod;479 readonly mergeMethod?: PullRequestMergeMethod;
480 readonly updateMethod?: PullRequestUpdateMethod;
421 }) => Effect.Effect<void, GitHubPullRequestCliError>;481 }) => Effect.Effect<void, GitHubPullRequestCliError>;
422482
423 readonly commentOnPullRequest: (input: {483 readonly commentOnPullRequest: (input: {
...@@ -453,6 +513,47 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -453,6 +513,47 @@ export class GitHubPullRequestCli extends Context.Service<
453 readonly threadId: string;513 readonly threadId: string;
454 readonly resolved: boolean;514 readonly resolved: boolean;
455 }) => Effect.Effect<void, GitHubPullRequestCliError>;515 }) => Effect.Effect<void, GitHubPullRequestCliError>;
516
517 /**
518 * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls
519 * reactable — a comment, a review, or the pull request itself, which is looked up here
520 * because nothing in the conversation names it. A given `subjectId` is confirmed to belong
521 * to this pull request before the mutation runs, since nothing else ties the two together.
522 */
523 readonly setReaction: (input: {
524 readonly cwd: string;
525 readonly repository: string;
526 readonly host: string;
527 readonly number: number;
528 readonly subjectId?: string | undefined;
529 readonly content: PullRequestReactionContent;
530 readonly reacted: boolean;
531 }) => Effect.Effect<void, GitHubPullRequestCliError>;
532
533 /** Rewrites the pull request's own words, leaving whichever of the two was not given. */
534 readonly updatePullRequest: (input: {
535 readonly cwd: string;
536 readonly repository: string;
537 readonly host: string;
538 readonly number: number;
539 readonly title?: string | undefined;
540 readonly body?: string | undefined;
541 }) => Effect.Effect<void, GitHubPullRequestCliError>;
542
543 /**
544 * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed
545 * to belong to this pull request before the mutation runs, the way a reaction subject is.
546 * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here.
547 */
548 readonly updateComment: (input: {
549 readonly cwd: string;
550 readonly repository: string;
551 readonly host: string;
552 readonly number: number;
553 readonly commentId: string;
554 readonly kind: "issue-comment" | "review-comment";
555 readonly body: string;
556 }) => Effect.Effect<void, GitHubPullRequestCliError>;
456 }557 }
457>()("t3/pullRequest/GitHubPullRequestCli") {}558>()("t3/pullRequest/GitHubPullRequestCli") {}
458559
...@@ -501,6 +602,76 @@ function searchPhrase(query: string): string {...@@ -501,6 +602,76 @@ function searchPhrase(query: string): string {
501 return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;602 return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
502}603}
503604
605/** GitHub's own spelling of a review state, which is not the contract's. */
606const REVIEW_QUALIFIERS = {
607 approved: "approved",
608 "changes-requested": "changes_requested",
609 "review-required": "required",
610 none: "none",
611} as const;
612
613/**
614 * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the
615 * one character that could end the quoted value early is dropped rather than escaped: no GitHub
616 * label or login holds a double quote, so there is nothing to preserve and everything to lose.
617 */
618function qualifierValue(value: string): string {
619 return `"${value.replaceAll('"', "").trim()}"`;
620}
621
622function filterQualifiers(
623 filters: PullRequestListFilters | undefined,
624 viewer: string,
625): ReadonlyArray<string> {
626 if (filters === undefined) return [];
627 return [
628 // One qualifier per group, its names joined by commas — GitHub's own OR.
629 ...(filters.labels ?? []).flatMap((group) =>
630 group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`],
631 ),
632 ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`),
633 ...(filters.author === undefined
634 ? []
635 : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]),
636 ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]),
637 ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]),
638 ...(filters.checks === undefined
639 ? []
640 : [`status:${filters.checks === "passing" ? "success" : "failure"}`]),
641 ];
642}
643
644/**
645 * The same narrowings over a row that has already arrived, for the search-free fallback. Every
646 * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by
647 * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an
648 * absent state on purpose — a row with no checks configured, or whose checks are still `pending`,
649 * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would
650 * not have surfaced for `status:success` or `status:failure`.
651 */
652function matchesFilters(
653 item: GitHubPullRequestListItem,
654 filters: PullRequestListFilters | undefined,
655 viewer: string,
656): boolean {
657 if (filters === undefined) return true;
658 const labels = item.labels.map((label) => label.name.trim().toLowerCase());
659 const holds = (label: string) => labels.includes(label.trim().toLowerCase());
660 return (
661 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
662 (filters.review === undefined ||
663 (filters.review === "none"
664 ? item.reviewDecision === null
665 : item.reviewDecision === filters.review)) &&
666 (filters.checks === undefined || item.checksState === filters.checks) &&
667 (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&
668 (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&
669 (filters.author === undefined ||
670 item.author?.login.toLowerCase() ===
671 resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase())
672 );
673}
674
504function involvementArgs(input: {675function involvementArgs(input: {
505 readonly state: PullRequestListState;676 readonly state: PullRequestListState;
506 readonly involvement: PullRequestInvolvement;677 readonly involvement: PullRequestInvolvement;
...@@ -513,6 +684,7 @@ function involvementArgs(input: {...@@ -513,6 +684,7 @@ function involvementArgs(input: {
513 * cannot use search at all and takes whatever order `gh pr list` answers in.684 * cannot use search at all and takes whatever order `gh pr list` answers in.
514 */685 */
515 readonly sorted: boolean;686 readonly sorted: boolean;
687 readonly filters?: PullRequestListFilters | undefined;
516}): ReadonlyArray<string> {688}): ReadonlyArray<string> {
517 // `--state closed` includes merged pull requests, so the Closed tab additionally excludes689 // `--state closed` includes merged pull requests, so the Closed tab additionally excludes
518 // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh`690 // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh`
...@@ -531,6 +703,7 @@ function involvementArgs(input: {...@@ -531,6 +703,7 @@ function involvementArgs(input: {
531 // sharing one instant are ordinary and the caller drops the ones it has already sent —703 // sharing one instant are ordinary and the caller drops the ones it has already sent —
532 // asking for strictly older would lose the rest of them instead.704 // asking for strictly older would lose the rest of them instead.
533 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),705 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),
706 ...filterQualifiers(input.filters, input.viewer),
534 // `gh pr list` answers newest-created first, which is not the order the page reads rows in707 // `gh pr list` answers newest-created first, which is not the order the page reads rows in
535 // and not an order a continuation can carry on from: a change request opened last year and708 // and not an order a continuation can carry on from: a change request opened last year and
536 // touched this morning belongs at the top of the list and at the front of the first slice.709 // touched this morning belongs at the top of the list and at the front of the first slice.
...@@ -550,6 +723,7 @@ function matchesUnsortedListing(...@@ -550,6 +723,7 @@ function matchesUnsortedListing(
550 readonly state: PullRequestListState;723 readonly state: PullRequestListState;
551 readonly involvement: PullRequestInvolvement;724 readonly involvement: PullRequestInvolvement;
552 readonly viewer: string;725 readonly viewer: string;
726 readonly filters?: PullRequestListFilters | undefined;
553 },727 },
554): boolean {728): boolean {
555 const matchesState = input.state === "all" || item.state === input.state;729 const matchesState = input.state === "all" || item.state === input.state;
...@@ -560,7 +734,7 @@ function matchesUnsortedListing(...@@ -560,7 +734,7 @@ function matchesUnsortedListing(
560 ? item.author?.login.toLowerCase() === viewer734 ? item.author?.login.toLowerCase() === viewer
561 : item.hasTeamReviewRequest ||735 : item.hasTeamReviewRequest ||
562 item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer));736 item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer));
563 return matchesState && matchesInvolvement;737 return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer);
564}738}
565739
566/** What a repository selector may hold before it goes into a search as itself. */740/** What a repository selector may hold before it goes into a search as itself. */
...@@ -587,6 +761,7 @@ function searchQuery(input: {...@@ -587,6 +761,7 @@ function searchQuery(input: {
587 readonly viewer: string;761 readonly viewer: string;
588 readonly query?: string | undefined;762 readonly query?: string | undefined;
589 readonly cursor?: ProviderListCursor | undefined;763 readonly cursor?: ProviderListCursor | undefined;
764 readonly filters?: PullRequestListFilters | undefined;
590}): string | null {765}): string | null {
591 if (input.repositories.length === 0) return null;766 if (input.repositories.length === 0) return null;
592 const repositories = input.repositories.map((repository) => repository.trim());767 const repositories = input.repositories.map((repository) => repository.trim());
...@@ -603,6 +778,7 @@ function searchQuery(input: {...@@ -603,6 +778,7 @@ function searchQuery(input: {
603 ...(query.length === 0 ? [] : [searchPhrase(query)]),778 ...(query.length === 0 ? [] : [searchPhrase(query)]),
604 // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives.779 // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives.
605 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),780 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),
781 ...filterQualifiers(input.filters, input.viewer),
606 // The order the page reads its rows in, and the only order a continuation can carry on from.782 // The order the page reads its rows in, and the only order a continuation can carry on from.
607 "sort:updated-desc",783 "sort:updated-desc",
608 ...repositories.map((repository) => `repo:${repository}`),784 ...repositories.map((repository) => `repo:${repository}`),
...@@ -621,10 +797,20 @@ function cursorVariable(cursor: string | null): readonly [string, string] {...@@ -621,10 +797,20 @@ function cursorVariable(cursor: string | null): readonly [string, string] {
621function actionArgs(797function actionArgs(
622 action: PullRequestAction,798 action: PullRequestAction,
623 mergeMethod: PullRequestMergeMethod | undefined,799 mergeMethod: PullRequestMergeMethod | undefined,
800 updateMethod: PullRequestUpdateMethod | undefined,
624): ReadonlyArray<string> {801): ReadonlyArray<string> {
625 switch (action) {802 switch (action) {
626 case "merge":803 case "merge":
627 return ["merge", `--${mergeMethod ?? "merge"}`];804 return ["merge", `--${mergeMethod ?? "merge"}`];
805 // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub
806 // stores the strategy with the standing instruction rather than choosing one at merge time.
807 case "enable-auto-merge":
808 return ["merge", "--auto", `--${mergeMethod ?? "merge"}`];
809 case "disable-auto-merge":
810 return ["merge", "--disable-auto"];
811 // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default.
812 case "update-branch":
813 return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])];
628 case "ready":814 case "ready":
629 return ["ready"];815 return ["ready"];
630 case "draft":816 case "draft":
...@@ -639,6 +825,62 @@ function actionArgs(...@@ -639,6 +825,62 @@ function actionArgs(
639export const make = Effect.gen(function* () {825export const make = Effect.gen(function* () {
640 const github = yield* GitHubCli.GitHubCli;826 const github = yield* GitHubCli.GitHubCli;
641827
828 /**
829 * The pull request's own node id, which is what a mutation against the pull request itself is
830 * addressed by: a reaction on its description, or a rewrite of its words.
831 */
832 const pullRequestNodeId = (input: {
833 readonly cwd: string;
834 readonly repository: string;
835 readonly host: string;
836 readonly number: number;
837 readonly operation: string;
838 }) => {
839 const { owner, name } = parseRepositorySelector(input.repository);
840 return graphqlRead({
841 cwd: input.cwd,
842 host: input.host,
843 operation: input.operation,
844 variables: [
845 ["-f", `owner=${owner}`],
846 ["-f", `name=${name}`],
847 ["-F", `number=${input.number}`],
848 ],
849 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
850 decode: decodePullRequestNodeIdJson,
851 });
852 };
853
854 /**
855 * Whether a client-given subject actually belongs to the pull request the request names. A
856 * subject id is trusted to be whatever node it names, and that node can hang off any pull
857 * request on the host — so the mutation itself would write wherever the id actually belongs,
858 * not wherever the request says it does, unless this confirms the two agree first.
859 */
860 const subjectBelongsToPullRequest = (input: {
861 readonly cwd: string;
862 readonly repository: string;
863 readonly host: string;
864 readonly number: number;
865 readonly subjectId: string;
866 readonly operation: string;
867 }) => {
868 const { owner, name } = parseRepositorySelector(input.repository);
869 return graphqlRead({
870 cwd: input.cwd,
871 host: input.host,
872 operation: input.operation,
873 variables: [
874 ["-f", `owner=${owner}`],
875 ["-f", `name=${name}`],
876 ["-F", `number=${input.number}`],
877 ["-f", `subjectId=${input.subjectId}`],
878 ],
879 query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
880 decode: decodeReactionSubjectScopeJson,
881 });
882 };
883
642 // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is884 // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is
643 // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own885 // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own
644 // install rather than to a same-named repository on github.com.886 // install rather than to a same-named repository on github.com.
...@@ -796,6 +1038,9 @@ export const make = Effect.gen(function* () {...@@ -796,6 +1038,9 @@ export const make = Effect.gen(function* () {
796 patch: decoded.success.patch,1038 patch: decoded.success.patch,
797 truncated: decoded.success.truncated,1039 truncated: decoded.success.truncated,
798 nextCursor: morePages ? String(input.page + 1) : null,1040 nextCursor: morePages ? String(input.page + 1) : null,
1041 ...(decoded.success.omittedFileStats.length === 0
1042 ? {}
1043 : { omittedFileStats: decoded.success.omittedFileStats }),
799 });1044 });
800 }),1045 }),
801 );1046 );
...@@ -977,10 +1222,15 @@ export const make = Effect.gen(function* () {...@@ -977,10 +1222,15 @@ export const make = Effect.gen(function* () {
977 // repository's whole list, which is every row the reader did not search for. The fallback1222 // repository's whole list, which is every row the reader did not search for. The fallback
978 // is for a repository the index does not cover, and a listing with no text to match is the1223 // is for a repository the index does not cover, and a listing with no text to match is the
979 // only place an empty answer can mean that.1224 // only place an empty answer can mean that.
980 const searched = (input.query?.trim().length ?? 0) > 0;1225 // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just
1226 // as well as search judges them over its own, so carrying them into the fallback answers
1227 // the same read rather than a wider one. Free text is the one thing the fallback cannot
1228 // judge locally — it lists rows, it does not search their text — so a query still rules
1229 // the fallback out: an empty answer under one is already the answer.
1230 const hasQuery = (input.query?.trim().length ?? 0) > 0;
981 return read(true).pipe(1231 return read(true).pipe(
982 Effect.flatMap((batch) =>1232 Effect.flatMap((batch) =>
983 batch.items.length === 0 && input.cursor === undefined && !searched1233 batch.items.length === 0 && input.cursor === undefined && !hasQuery
984 ? read(false)1234 ? read(false)
985 : Effect.succeed(batch),1235 : Effect.succeed(batch),
986 ),1236 ),
...@@ -1085,6 +1335,23 @@ export const make = Effect.gen(function* () {...@@ -1085,6 +1335,23 @@ export const make = Effect.gen(function* () {
1085 }),1335 }),
1086 ),1336 ),
10871337
1338 getPullRequestBaseComparison: (input) => {
1339 const { owner, name } = parseRepositorySelector(input.repository);
1340 return graphqlRead({
1341 cwd: input.cwd,
1342 host: input.host,
1343 operation: "getPullRequestBaseComparison",
1344 variables: [
1345 ["-f", `owner=${owner}`],
1346 ["-f", `name=${name}`],
1347 ["-F", `number=${input.number}`],
1348 ["-f", `headRef=${input.headRef}`],
1349 ],
1350 query: BASE_COMPARISON_GRAPHQL_QUERY,
1351 decode: decodeBaseComparisonJson,
1352 });
1353 },
1354
1088 getPullRequestActivity: (input) =>1355 getPullRequestActivity: (input) =>
1089 github1356 github
1090 .execute({1357 .execute({
...@@ -1216,8 +1483,12 @@ export const make = Effect.gen(function* () {...@@ -1216,8 +1483,12 @@ export const make = Effect.gen(function* () {
1216 { readonly additions: number; readonly deletions: number }1483 { readonly additions: number; readonly deletions: number }
1217 >();1484 >();
1218 let reviewers: ReadonlyArray<PullRequestActor> = [];1485 let reviewers: ReadonlyArray<PullRequestActor> = [];
1486 let reactions: GitHubReviewThreadPage["reactions"] = [];
1487 const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
1219 let commits: GitHubReviewThreadPage["commits"] = [];1488 let commits: GitHubReviewThreadPage["commits"] = [];
1220 let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false };1489 let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false };
1490 const dismissalsByReviewId = new Map<string, string>();
1491 let dismissalCursor: string | null = null;
1221 let cursor: string | null = null;1492 let cursor: string | null = null;
1222 let page = 0;1493 let page = 0;
1223 do {1494 do {
...@@ -1229,14 +1500,46 @@ export const make = Effect.gen(function* () {...@@ -1229,14 +1500,46 @@ export const make = Effect.gen(function* () {
1229 // first one already carries all of them.1500 // first one already carries all of them.
1230 if (page === 0) {1501 if (page === 0) {
1231 reviewers = read.reviewers;1502 reviewers = read.reviewers;
1503 reactions = read.reactions;
1504 for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry);
1232 commits = read.commits;1505 commits = read.commits;
1233 viewer = read.viewer;1506 viewer = read.viewer;
1507 for (const [id, message] of read.dismissalsByReviewId)
1508 dismissalsByReviewId.set(id, message);
1509 dismissalCursor = read.nextDismissalCursor;
1234 for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat);1510 for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat);
1235 }1511 }
1236 cursor = read.nextCursor;1512 cursor = read.nextCursor;
1237 page += 1;1513 page += 1;
1238 } while (cursor !== null && page < REVIEW_THREAD_PAGES);1514 } while (cursor !== null && page < REVIEW_THREAD_PAGES);
12391515
1516 // Almost never entered: the embedded page already holds every dismissal a pull request
1517 // ordinarily accrues. Followed so a review whose event fell past that page still finds
1518 // its reason.
1519 let dismissalPage = 0;
1520 while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) {
1521 const read: {
1522 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
1523 readonly nextCursor: string | null;
1524 } = yield* graphqlRead({
1525 cwd: input.cwd,
1526 host: input.host,
1527 operation: "listReviewThreadComments",
1528 variables: [
1529 ["-f", `owner=${owner}`],
1530 ["-f", `name=${name}`],
1531 ["-F", `number=${input.number}`],
1532 ["-f", `cursor=${dismissalCursor}`],
1533 ],
1534 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
1535 decode: decodeReviewDismissalsJson,
1536 });
1537 for (const [id, message] of read.dismissalsByReviewId)
1538 dismissalsByReviewId.set(id, message);
1539 dismissalCursor = read.nextCursor;
1540 dismissalPage += 1;
1541 }
1542
1240 // Only the threads GitHub said were unfinished cost a request; the rest arrived whole1543 // Only the threads GitHub said were unfinished cost a request; the rest arrived whole
1241 // with the page they were listed on.1544 // with the page they were listed on.
1242 const finished = yield* Effect.forEach(1545 const finished = yield* Effect.forEach(
...@@ -1264,11 +1567,14 @@ export const make = Effect.gen(function* () {...@@ -1264,11 +1567,14 @@ export const make = Effect.gen(function* () {
1264 const reviewThreads = finished.map((entry) => entry.thread);1567 const reviewThreads = finished.map((entry) => entry.thread);
1265 return {1568 return {
1266 comments: reviewThreadConversation(reviewThreads),1569 comments: reviewThreadConversation(reviewThreads),
1570 dismissalsByReviewId,
1267 reviewThreads,1571 reviewThreads,
1268 // GitHub's own count of each thread, so the number the page shows is the host's even1572 // GitHub's own count of each thread, so the number the page shows is the host's even
1269 // where a bound kept some of the words on GitHub.1573 // where a bound kept some of the words on GitHub.
1270 commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0),1574 commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0),
1271 truncated: cursor !== null || finished.some((entry) => entry.truncated),1575 truncated: cursor !== null || finished.some((entry) => entry.truncated),
1576 reactions,
1577 reactionsById,
1272 reviewers,1578 reviewers,
1273 avatarsByLogin,1579 avatarsByLogin,
1274 commitStats,1580 commitStats,
...@@ -1396,7 +1702,11 @@ export const make = Effect.gen(function* () {...@@ -1396,7 +1702,11 @@ export const make = Effect.gen(function* () {
1396 },1702 },
13971703
1398 runPullRequestAction: (input) => {1704 runPullRequestAction: (input) => {
1399 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);1705 const [subcommand, ...flags] = actionArgs(
1706 input.action,
1707 input.mergeMethod,
1708 input.updateMethod,
1709 );
1400 return github1710 return github
1401 .execute({1711 .execute({
1402 cwd: input.cwd,1712 cwd: input.cwd,
...@@ -1467,6 +1777,91 @@ export const make = Effect.gen(function* () {...@@ -1467,6 +1777,91 @@ export const make = Effect.gen(function* () {
1467 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,1777 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
1468 variables: { threadId: input.threadId },1778 variables: { threadId: input.threadId },
1469 }),1779 }),
1780
1781 setReaction: (input) => {
1782 const givenSubjectId = input.subjectId;
1783 const subjectId =
1784 givenSubjectId === undefined
1785 ? pullRequestNodeId({ ...input, operation: "setReaction" })
1786 : subjectBelongsToPullRequest({
1787 ...input,
1788 subjectId: givenSubjectId,
1789 operation: "setReaction",
1790 }).pipe(
1791 Effect.flatMap((belongs) =>
1792 belongs
1793 ? Effect.succeed(givenSubjectId)
1794 : Effect.fail(
1795 new GitHubSubjectScopeError({
1796 command: "gh",
1797 cwd: input.cwd,
1798 operation: "setReaction",
1799 }),
1800 ),
1801 ),
1802 );
1803 return subjectId.pipe(
1804 Effect.flatMap((subjectId) =>
1805 graphql({
1806 cwd: input.cwd,
1807 host: input.host,
1808 query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION,
1809 variables: { subjectId, content: gitHubReactionContent(input.content) },
1810 }),
1811 ),
1812 );
1813 },
1814
1815 updatePullRequest: (input) =>
1816 pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe(
1817 Effect.flatMap((pullRequestId) =>
1818 graphql({
1819 cwd: input.cwd,
1820 host: input.host,
1821 query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
1822 // A field the caller did not name is left out of the request entirely, so GitHub
1823 // keeps the words that are there rather than being asked for an empty one.
1824 variables: {
1825 pullRequestId,
1826 ...(input.title === undefined ? {} : { title: input.title }),
1827 ...(input.body === undefined ? {} : { body: input.body }),
1828 },
1829 }),
1830 ),
1831 ),
1832
1833 updateComment: (input) =>
1834 subjectBelongsToPullRequest({
1835 cwd: input.cwd,
1836 repository: input.repository,
1837 host: input.host,
1838 number: input.number,
1839 subjectId: input.commentId,
1840 operation: "updateComment",
1841 }).pipe(
1842 Effect.flatMap((belongs) =>
1843 belongs
1844 ? Effect.succeed(input.commentId)
1845 : Effect.fail(
1846 new GitHubSubjectScopeError({
1847 command: "gh",
1848 cwd: input.cwd,
1849 operation: "updateComment",
1850 }),
1851 ),
1852 ),
1853 Effect.flatMap((commentId) =>
1854 graphql({
1855 cwd: input.cwd,
1856 host: input.host,
1857 query:
1858 input.kind === "issue-comment"
1859 ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION
1860 : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
1861 variables: { commentId, body: input.body },
1862 }),
1863 ),
1864 ),
1470 });1865 });
1471});1866});
14721867
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+230-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import type { PullRequestReaction } from "@t3tools/contracts";
45
5import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";6import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
6import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";7import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
...@@ -9,7 +10,16 @@ import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";...@@ -9,7 +10,16 @@ import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
9describe("gitHubViewerPermissions", () => {10describe("gitHubViewerPermissions", () => {
10 it("offers everything to a viewer who can write to the repository", () => {11 it("offers everything to a viewer who can write to the repository", () => {
11 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({12 expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({
12 actions: ["merge", "ready", "draft", "close", "reopen"],13 // Arming a merge for later is the merge, so it travels with it.
14 actions: [
15 "merge",
16 "enable-auto-merge",
17 "disable-auto-merge",
18 "ready",
19 "draft",
20 "close",
21 "reopen",
22 ],
13 comment: true,23 comment: true,
14 resolve: true,24 resolve: true,
15 verdicts: ["comment", "approve", "request-changes"],25 verdicts: ["comment", "approve", "request-changes"],
...@@ -34,7 +44,7 @@ describe("gitHubViewerPermissions", () => {...@@ -34,7 +44,7 @@ describe("gitHubViewerPermissions", () => {
3444
35 it("keeps an author's own pull request theirs to close, with read access and no more", () => {45 it("keeps an author's own pull request theirs to close, with read access and no more", () => {
36 expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({46 expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({
37 // Merging is the one thing writing is needed for; the rest an author may do.47 // Merging is the one thing writing is needed for, now or later; the rest an author may do.
38 actions: ["ready", "draft", "close", "reopen"],48 actions: ["ready", "draft", "close", "reopen"],
39 comment: true,49 comment: true,
40 resolve: true,50 resolve: true,
...@@ -71,17 +81,20 @@ describe("gitHubViewerPermissions", () => {...@@ -71,17 +81,20 @@ describe("gitHubViewerPermissions", () => {
71 title: "Pull request 7",81 title: "Pull request 7",
72 url: "https://github.com/acme/web/pull/7",82 url: "https://github.com/acme/web/pull/7",
73 author: null,83 author: null,
84 headRepositoryOwner: null,
74 headBranch: "feat/page",85 headBranch: "feat/page",
75 baseBranch: "main",86 baseBranch: "main",
76 state: "open",87 state: "open",
77 isDraft: false,88 isDraft: false,
78 mergeability: "mergeable",89 mergeability: "mergeable",
90 reviewDecision: null,
79 additions: 1,91 additions: 1,
80 deletions: 1,92 deletions: 1,
81 createdAt: "2026-07-01T00:00:00Z",93 createdAt: "2026-07-01T00:00:00Z",
82 updatedAt: "2026-07-02T00:00:00Z",94 updatedAt: "2026-07-02T00:00:00Z",
83 reviewRequestLogins: [],95 reviewRequestLogins: [],
84 hasTeamReviewRequest: false,96 hasTeamReviewRequest: false,
97 checksState: null,
85 labels: [],98 labels: [],
86 body: "",99 body: "",
87 changedFiles: 1,100 changedFiles: 1,
...@@ -104,6 +117,101 @@ describe("gitHubViewerPermissions", () => {...@@ -104,6 +117,101 @@ describe("gitHubViewerPermissions", () => {
104 );117 );
105});118});
106119
120describe("getViewerPermissions", () => {
121 const openDetail = {
122 authorId: null,
123 number: 7,
124 title: "Pull request 7",
125 url: "https://github.com/acme/web/pull/7",
126 author: null,
127 headRepositoryOwner: "acme",
128 headBranch: "feat/page",
129 baseBranch: "main",
130 state: "open" as const,
131 isDraft: false,
132 mergeability: "mergeable" as const,
133 reviewDecision: null,
134 additions: 1,
135 deletions: 1,
136 createdAt: "2026-07-01T00:00:00Z",
137 updatedAt: "2026-07-02T00:00:00Z",
138 reviewRequestLogins: [],
139 hasTeamReviewRequest: false,
140 checksState: null,
141 labels: [],
142 body: "",
143 changedFiles: 1,
144 mergedAt: null,
145 closedAt: null,
146 checks: [],
147 comments: [],
148 commits: [],
149 };
150
151 const layerWithComparison = (
152 comparison: Effect.Effect<{
153 readonly behindBy: number | null;
154 readonly viewerCanUpdate: boolean;
155 }>,
156 ) =>
157 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
158 getPullRequestDetail: () => Effect.succeed(openDetail),
159 getPullRequestBaseComparison: () => comparison,
160 getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
161 });
162
163 it.effect("offers update-branch when the comparison grants it", () =>
164 Effect.gen(function* () {
165 const provider = yield* make;
166 const permissions = yield* provider.getViewerPermissions({
167 cwd: "/w",
168 repository: "acme/web",
169 host: "github.com",
170 number: 7,
171 });
172
173 expect(permissions.actions).toContain("update-branch");
174 expect(permissions.updateMethods).toEqual(["merge", "rebase"]);
175 }).pipe(
176 Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))),
177 ),
178 );
179
180 it.effect("withholds update-branch when the comparison cannot be read", () =>
181 Effect.gen(function* () {
182 const provider = yield* make;
183 const permissions = yield* provider.getViewerPermissions({
184 cwd: "/w",
185 repository: "acme/web",
186 host: "github.com",
187 number: 7,
188 });
189
190 expect(permissions.actions).not.toContain("update-branch");
191 expect(permissions.updateMethods).toBeUndefined();
192 // The rest of the answer survives a comparison nobody could make.
193 expect(permissions.actions).toContain("merge");
194 }).pipe(
195 Effect.provide(
196 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
197 getPullRequestDetail: () => Effect.succeed(openDetail),
198 getPullRequestBaseComparison: () =>
199 Effect.fail(
200 new GitHubPullRequestCli.GitHubPullRequestReadError({
201 command: "gh",
202 cwd: "/w",
203 operation: "getPullRequestBaseComparison",
204 cause: new Error("unreadable"),
205 }),
206 ),
207 getViewerAccess: () =>
208 Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
209 }),
210 ),
211 ),
212 );
213});
214
107describe("getChangeRequest commits", () => {215describe("getChangeRequest commits", () => {
108 const baseDetail = {216 const baseDetail = {
109 authorId: null,217 authorId: null,
...@@ -122,6 +230,7 @@ describe("getChangeRequest commits", () => {...@@ -122,6 +230,7 @@ describe("getChangeRequest commits", () => {
122 updatedAt: "2026-07-02T00:00:00Z",230 updatedAt: "2026-07-02T00:00:00Z",
123 reviewRequestLogins: [],231 reviewRequestLogins: [],
124 hasTeamReviewRequest: false,232 hasTeamReviewRequest: false,
233 checksState: null,
125 labels: [],234 labels: [],
126 body: "",235 body: "",
127 changedFiles: 1,236 changedFiles: 1,
...@@ -133,9 +242,12 @@ describe("getChangeRequest commits", () => {...@@ -133,9 +242,12 @@ describe("getChangeRequest commits", () => {
133242
134 const baseThreadComments = {243 const baseThreadComments = {
135 comments: [],244 comments: [],
245 dismissalsByReviewId: new Map<string, string>(),
136 reviewThreads: [],246 reviewThreads: [],
137 commentCount: 0,247 commentCount: 0,
138 truncated: false,248 truncated: false,
249 reactions: [],
250 reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
139 reviewers: [],251 reviewers: [],
140 avatarsByLogin: new Map<string, string>(),252 avatarsByLogin: new Map<string, string>(),
141 commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(),253 commitStats: new Map<string, { readonly additions: number; readonly deletions: number }>(),
...@@ -200,6 +312,122 @@ describe("getChangeRequest commits", () => {...@@ -200,6 +312,122 @@ describe("getChangeRequest commits", () => {
200 );312 );
201});313});
202314
315describe("getChangeRequestActivity dismissed reviews", () => {
316 const dismissedReview = (body: string) => ({
317 id: "PRR_1",
318 kind: "review" as const,
319 author: null,
320 body,
321 createdAt: "2026-07-03T00:00:00Z",
322 url: null,
323 path: null,
324 reviewState: "DISMISSED",
325 });
326 const threadComments: GitHubReviewThreadComments = {
327 comments: [],
328 dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]),
329 reviewThreads: [],
330 commentCount: 0,
331 truncated: false,
332 reactions: [],
333 reactionsById: new Map(),
334 reviewers: [],
335 avatarsByLogin: new Map(),
336 commitStats: new Map(),
337 commits: [],
338 viewer: { canUpdate: true, didAuthor: false },
339 };
340 const layerFor = (body: string) =>
341 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
342 getPullRequestActivity: () =>
343 Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }),
344 listReviewThreadComments: () => Effect.succeed(threadComments),
345 });
346 const readActivity = Effect.gen(function* () {
347 const provider = yield* make;
348 return yield* provider.getChangeRequestActivity({
349 cwd: "/w",
350 repository: "acme/web",
351 host: "github.com",
352 number: 7,
353 });
354 });
355
356 it.effect("fills a marker-only dismissed review with the timeline's reason", () =>
357 // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing —
358 // an empty-string check misses them and the card opens onto nothing.
359 readActivity.pipe(
360 Effect.map((activity) => {
361 expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581");
362 }),
363 Effect.provide(layerFor("<!-- Macroscope (Approvability) review body marker -->")),
364 ),
365 );
366
367 it.effect("keeps the words of a dismissed review that has its own", () =>
368 readActivity.pipe(
369 Effect.map((activity) => {
370 expect(activity.comments[0]?.body).toBe("These findings still stand.");
371 }),
372 Effect.provide(layerFor("These findings still stand.")),
373 ),
374 );
375});
376
377describe("editing", () => {
378 const rewrites: Array<unknown> = [];
379
380 it.effect("hands a rewrite to the CLI as the request named it", () =>
381 Effect.gen(function* () {
382 const provider = yield* make;
383
384 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true });
385 yield* provider.updateChangeRequest!({
386 cwd: "/w",
387 repository: "acme/web",
388 host: "github.com",
389 number: 7,
390 title: "A better title",
391 });
392 yield* provider.updateComment!({
393 cwd: "/w",
394 repository: "acme/web",
395 host: "github.com",
396 number: 7,
397 commentId: "IC_1",
398 kind: "review-comment",
399 body: "Reworded.",
400 });
401
402 expect(rewrites).toEqual([
403 {
404 cwd: "/w",
405 repository: "acme/web",
406 host: "github.com",
407 number: 7,
408 title: "A better title",
409 },
410 {
411 cwd: "/w",
412 repository: "acme/web",
413 host: "github.com",
414 number: 7,
415 commentId: "IC_1",
416 kind: "review-comment",
417 body: "Reworded.",
418 },
419 ]);
420 }).pipe(
421 Effect.provide(
422 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
423 updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)),
424 updateComment: (input) => Effect.sync(() => void rewrites.push(input)),
425 }),
426 ),
427 ),
428 );
429});
430
203describe("loginAvatarUrl", () => {431describe("loginAvatarUrl", () => {
204 it("serves a user's picture from the host they belong to", () => {432 it("serves a user's picture from the host they belong to", () => {
205 expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80");433 expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80");
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+145-10
...@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect";...@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect";
2import type {2import type {
3 PullRequestActor,3 PullRequestActor,
4 PullRequestCapabilities,4 PullRequestCapabilities,
5 PullRequestReaction,
5 PullRequestViewerPermissions,6 PullRequestViewerPermissions,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
78
...@@ -17,9 +18,20 @@ import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts";...@@ -17,9 +18,20 @@ import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts";
17const CAPABILITIES: PullRequestCapabilities = {18const CAPABILITIES: PullRequestCapabilities = {
18 diff: true,19 diff: true,
19 comment: true,20 comment: true,
20 actions: ["merge", "ready", "draft", "close", "reopen"],21 actions: [
22 "merge",
23 "ready",
24 "draft",
25 "close",
26 "reopen",
27 "update-branch",
28 "enable-auto-merge",
29 "disable-auto-merge",
30 ],
21 mergeMethods: ["merge", "squash", "rebase"],31 mergeMethods: ["merge", "squash", "rebase"],
32 updateMethods: ["merge", "rebase"],
22 search: true,33 search: true,
34 reactions: true,
23 review: {35 review: {
24 inlineComment: true,36 inlineComment: true,
25 reply: true,37 reply: true,
...@@ -27,6 +39,7 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -27,6 +39,7 @@ const CAPABILITIES: PullRequestCapabilities = {
27 verdicts: ["comment", "approve", "request-changes"],39 verdicts: ["comment", "approve", "request-changes"],
28 },40 },
29 reviewers: { request: true, listCandidates: true },41 reviewers: { request: true, listCandidates: true },
42 edit: { changeRequest: true, comment: true },
30};43};
3144
32/**45/**
...@@ -49,8 +62,13 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -49,8 +62,13 @@ const CAPABILITIES: PullRequestCapabilities = {
49export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions {62export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions {
50 return {63 return {
51 actions: [64 actions: [
52 ...(access.canWrite ? (["merge"] as const) : []),65 // Arming a merge and taking the arming back are the merge, deferred: whoever may not
66 // merge here may not leave an instruction to merge later either.
67 ...(access.canWrite ? (["merge", "enable-auto-merge", "disable-auto-merge"] as const) : []),
53 ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []),68 ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []),
69 // Whether this viewer may update the branch is GitHub's own answer, read with the
70 // comparison; without it the action is offered to nobody rather than to everybody.
71 ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []),
54 ],72 ],
55 comment: true,73 comment: true,
56 resolve: access.canWrite || access.didAuthor,74 resolve: access.canWrite || access.didAuthor,
...@@ -59,6 +77,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest...@@ -59,6 +77,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest
59 // leaves them commenting, which is what an author has to say about their own change anyway.77 // leaves them commenting, which is what an author has to say about their own change anyway.
60 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,78 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,
61 requestReviewers: access.canWrite,79 requestReviewers: access.canWrite,
80 ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}),
62 };81 };
63}82}
6483
...@@ -98,6 +117,10 @@ export function loginAvatarUrl(login: string, host: string): string | null {...@@ -98,6 +117,10 @@ export function loginAvatarUrl(login: string, host: string): string | null {
98 return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null;117 return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null;
99}118}
100119
120/** True where markdown would render nothing: whitespace, or only HTML comments. */
121const rendersEmpty = (body: string): boolean =>
122 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;
123
101export const make = Effect.gen(function* () {124export const make = Effect.gen(function* () {
102 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;125 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
103126
...@@ -129,6 +152,7 @@ export const make = Effect.gen(function* () {...@@ -129,6 +152,7 @@ export const make = Effect.gen(function* () {
129 limit: input.limit,152 limit: input.limit,
130 query: input.query,153 query: input.query,
131 cursor: input.cursor,154 cursor: input.cursor,
155 filters: input.filters,
132 })156 })
133 .pipe(157 .pipe(
134 Effect.mapError(fail("listChangeRequests")),158 Effect.mapError(fail("listChangeRequests")),
...@@ -172,6 +196,7 @@ export const make = Effect.gen(function* () {...@@ -172,6 +196,7 @@ export const make = Effect.gen(function* () {
172 limit: input.limit,196 limit: input.limit,
173 query: input.query,197 query: input.query,
174 cursor: input.cursor,198 cursor: input.cursor,
199 filters: input.filters,
175 })200 })
176 .pipe(201 .pipe(
177 Effect.mapError(fail("listChangeRequestsAcross")),202 Effect.mapError(fail("listChangeRequestsAcross")),
...@@ -196,7 +221,24 @@ export const make = Effect.gen(function* () {...@@ -196,7 +221,24 @@ export const make = Effect.gen(function* () {
196 getChangeRequest: (input) =>221 getChangeRequest: (input) =>
197 Effect.all(222 Effect.all(
198 [223 [
199 cli.getPullRequestDetail(input),224 cli.getPullRequestDetail(input).pipe(
225 Effect.flatMap((pullRequest) =>
226 // Only an open pull request can be behind anything worth saying so about, and only
227 // one whose head repository is known can be compared at all. A comparison that
228 // fails is left unknown: the banner is an offer, never a blocker.
229 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
230 ? Effect.succeed({ pullRequest, comparison: null })
231 : cli
232 .getPullRequestBaseComparison({
233 ...input,
234 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
235 })
236 .pipe(
237 Effect.map((comparison) => ({ pullRequest, comparison })),
238 Effect.orElseSucceed(() => ({ pullRequest, comparison: null })),
239 ),
240 ),
241 ),
200 cli.getRepositoryAccess({242 cli.getRepositoryAccess({
201 cwd: input.cwd,243 cwd: input.cwd,
202 repository: input.repository,244 repository: input.repository,
...@@ -210,15 +252,27 @@ export const make = Effect.gen(function* () {...@@ -210,15 +252,27 @@ export const make = Effect.gen(function* () {
210 ).pipe(252 ).pipe(
211 Effect.mapError(fail("getChangeRequest")),253 Effect.mapError(fail("getChangeRequest")),
212 Effect.map(254 Effect.map(
213 ([pullRequest, repository, viewerAccess]): ProviderChangeRequestDetail => ({255 ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({
214 ...pullRequest,256 ...detail.pullRequest,
215 reviewers: pullRequest.reviewRequestLogins.map((login) => ({257 reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({
216 login,258 login,
217 name: null,259 name: null,
218 avatarUrl: null,260 avatarUrl: null,
219 })),261 })),
220 mergeCapabilities: repository.mergeCapabilities,262 mergeCapabilities: repository.mergeCapabilities,
221 viewerPermissions: gitHubViewerPermissions(viewerAccess),263 viewerPermissions: gitHubViewerPermissions({
264 ...viewerAccess,
265 canUpdateBranch: detail.comparison?.viewerCanUpdate === true,
266 }),
267 baseComparison:
268 detail.comparison === null || detail.comparison.behindBy === null
269 ? "unknown"
270 : detail.comparison.behindBy > 0
271 ? "behind"
272 : "up-to-date",
273 ...(detail.comparison?.behindBy == null
274 ? {}
275 : { behindBy: detail.comparison.behindBy }),
222 }),276 }),
223 ),277 ),
224 ),278 ),
...@@ -232,6 +286,9 @@ export const make = Effect.gen(function* () {...@@ -232,6 +286,9 @@ export const make = Effect.gen(function* () {
232 cli.listReviewThreadComments(input).pipe(286 cli.listReviewThreadComments(input).pipe(
233 Effect.orElseSucceed(() => ({287 Effect.orElseSucceed(() => ({
234 comments: [],288 comments: [],
289 dismissalsByReviewId: new Map<string, string>(),
290 reactions: [],
291 reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
235 reviewThreads: [],292 reviewThreads: [],
236 commentCount: 0,293 commentCount: 0,
237 truncated: true,294 truncated: true,
...@@ -253,6 +310,7 @@ export const make = Effect.gen(function* () {...@@ -253,6 +310,7 @@ export const make = Effect.gen(function* () {
253 ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({310 ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({
254 author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host),311 author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host),
255 reviewers: reviewThreads.reviewers,312 reviewers: reviewThreads.reviewers,
313 reactions: reviewThreads.reactions,
256 commits: (reviewThreads.commits.length > 0314 commits: (reviewThreads.commits.length > 0
257 ? reviewThreads.commits315 ? reviewThreads.commits
258 : pullRequest.commits316 : pullRequest.commits
...@@ -266,7 +324,20 @@ export const make = Effect.gen(function* () {...@@ -266,7 +324,20 @@ export const make = Effect.gen(function* () {
266 comments: [...pullRequest.comments, ...reviewThreads.comments]324 comments: [...pullRequest.comments, ...reviewThreads.comments]
267 .map((comment) => ({325 .map((comment) => ({
268 ...comment,326 ...comment,
327 // GitHub keeps the dismissal reason on the timeline event, not on the review,
328 // so a dismissed review with nothing visible of its own reads its words from
329 // there. "Visible" and not "empty": bot reviews often carry only an HTML
330 // marker comment, which markdown renders as nothing.
331 body:
332 comment.kind === "review" &&
333 comment.reviewState?.toUpperCase() === "DISMISSED" &&
334 rendersEmpty(comment.body)
335 ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body)
336 : comment.body,
269 author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host),337 author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host),
338 // A comment out of `gh pr view --json` carries none of its own: that read
339 // reports no reaction at all, so they arrive from the GraphQL page by node id.
340 reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [],
270 }))341 }))
271 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),342 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
272 // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two343 // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two
...@@ -285,9 +356,34 @@ export const make = Effect.gen(function* () {...@@ -285,9 +356,34 @@ export const make = Effect.gen(function* () {
285 ),356 ),
286357
287 getViewerPermissions: (input) =>358 getViewerPermissions: (input) =>
288 cli359 Effect.all(
289 .getViewerAccess(input)360 [
290 .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitHubViewerPermissions)),361 cli.getViewerAccess(input),
362 // Whether this viewer may update the branch is only on the comparison, and the
363 // comparison only resolves through the head ref the detail carries. A failure here
364 // withholds that one action rather than the whole answer, the way the detail path
365 // leaves the banner unknown.
366 cli.getPullRequestDetail(input).pipe(
367 Effect.flatMap((pullRequest) =>
368 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
369 ? Effect.succeed(false)
370 : cli
371 .getPullRequestBaseComparison({
372 ...input,
373 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
374 })
375 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
376 ),
377 Effect.orElseSucceed(() => false),
378 ),
379 ],
380 { concurrency: 2 },
381 ).pipe(
382 Effect.mapError(fail("getViewerPermissions")),
383 Effect.map(([access, canUpdateBranch]) =>
384 gitHubViewerPermissions({ ...access, canUpdateBranch }),
385 ),
386 ),
291387
292 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),388 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
293389
...@@ -318,11 +414,37 @@ export const make = Effect.gen(function* () {...@@ -318,11 +414,37 @@ export const make = Effect.gen(function* () {
318 number: input.number,414 number: input.number,
319 action: input.action,415 action: input.action,
320 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),416 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
417 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
321 })418 })
322 .pipe(Effect.mapError(fail("runAction"))),419 .pipe(Effect.mapError(fail("runAction"))),
323420
421 updateChangeRequest: (input) =>
422 cli
423 .updatePullRequest({
424 cwd: input.cwd,
425 repository: input.repository,
426 host: input.host,
427 number: input.number,
428 ...(input.title === undefined ? {} : { title: input.title }),
429 ...(input.body === undefined ? {} : { body: input.body }),
430 })
431 .pipe(Effect.mapError(fail("updateChangeRequest"))),
432
324 comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))),433 comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))),
325434
435 updateComment: (input) =>
436 cli
437 .updateComment({
438 cwd: input.cwd,
439 repository: input.repository,
440 host: input.host,
441 number: input.number,
442 commentId: input.commentId,
443 kind: input.kind,
444 body: input.body,
445 })
446 .pipe(Effect.mapError(fail("updateComment"))),
447
326 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),448 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
327449
328 replyToThread: (input) =>450 replyToThread: (input) =>
...@@ -336,6 +458,19 @@ export const make = Effect.gen(function* () {...@@ -336,6 +458,19 @@ export const make = Effect.gen(function* () {
336 })458 })
337 .pipe(Effect.mapError(fail("replyToThread"))),459 .pipe(Effect.mapError(fail("replyToThread"))),
338460
461 setReaction: (input) =>
462 cli
463 .setReaction({
464 cwd: input.cwd,
465 repository: input.repository,
466 host: input.host,
467 number: input.number,
468 ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }),
469 content: input.content,
470 reacted: input.reacted,
471 })
472 .pipe(Effect.mapError(fail("setReaction"))),
473
339 setThreadResolution: (input) =>474 setThreadResolution: (input) =>
340 cli475 cli
341 .setReviewThreadResolution({476 .setReviewThreadResolution({
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts+256
...@@ -414,6 +414,69 @@ layer("GitLabPullRequestCli.layer", (it) => {...@@ -414,6 +414,69 @@ layer("GitLabPullRequestCli.layer", (it) => {
414 }),414 }),
415 );415 );
416416
417 it.effect("arms auto-merge with the same strategy a merge would have used", () =>
418 Effect.gen(function* () {
419 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
420 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
421
422 yield* cli.runMergeRequestAction({
423 cwd: "/w",
424 repository: "acme/web",
425 number: 7,
426 action: "enable-auto-merge",
427 mergeMethod: "squash",
428 });
429
430 expect(argsOfCall(0)).toEqual([
431 "mr",
432 "merge",
433 "7",
434 "--repo",
435 "acme/web",
436 "--auto-merge=true",
437 "--yes",
438 "--squash",
439 ]);
440 }),
441 );
442
443 it.effect("cancels an armed auto-merge through the API glab has no flag for", () =>
444 Effect.gen(function* () {
445 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
446 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
447
448 yield* cli.runMergeRequestAction({
449 cwd: "/w",
450 repository: "acme/platform/web",
451 number: 7,
452 action: "disable-auto-merge",
453 });
454
455 expect(argsOfCall(0)).toEqual([
456 "api",
457 "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds",
458 "--method",
459 "POST",
460 ]);
461 }),
462 );
463
464 it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () =>
465 Effect.gen(function* () {
466 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
467 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
468
469 yield* cli.runMergeRequestAction({
470 cwd: "/w",
471 repository: "acme/web",
472 number: 7,
473 action: "update-branch",
474 });
475
476 expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]);
477 }),
478 );
479
417 it.effect("moves a merge request back to draft through glab", () =>480 it.effect("moves a merge request back to draft through glab", () =>
418 Effect.gen(function* () {481 Effect.gen(function* () {
419 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));482 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));
...@@ -791,6 +854,21 @@ layer("GitLabPullRequestCli.layer", (it) => {...@@ -791,6 +854,21 @@ layer("GitLabPullRequestCli.layer", (it) => {
791 }),854 }),
792 );855 );
793856
857 it.effect("asks the detail read for the divergence GitLab withholds by default", () =>
858 Effect.gen(function* () {
859 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));
860 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
861
862 yield* Effect.ignore(
863 cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }),
864 );
865
866 expect(argsOfCall(0)[1]).toBe(
867 "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true",
868 );
869 }),
870 );
871
794 it.effect("fails the read when GitLab returns something unreadable", () =>872 it.effect("fails the read when GitLab returns something unreadable", () =>
795 Effect.gen(function* () {873 Effect.gen(function* () {
796 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));874 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}')));
...@@ -1008,6 +1086,89 @@ layer("GitLabPullRequestCli.layer", (it) => {...@@ -1008,6 +1086,89 @@ layer("GitLabPullRequestCli.layer", (it) => {
1008 }),1086 }),
1009 );1087 );
10101088
1089 it.effect("awards an emoji through a POST naming it, not a body", () =>
1090 Effect.gen(function* () {
1091 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1092 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1093
1094 yield* cli.setReaction({
1095 cwd: "/w",
1096 repository: "acme/web",
1097 number: 7,
1098 content: "thumbs-up",
1099 reacted: true,
1100 });
1101
1102 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1103 expect(argsOfCall(0)).toEqual([
1104 "api",
1105 "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup",
1106 "--method",
1107 "POST",
1108 ]);
1109 }),
1110 );
1111
1112 it.effect("removes an award by listing them and deleting the reader's own id", () =>
1113 Effect.gen(function* () {
1114 mockedExecute.mockReturnValueOnce(
1115 // @effect-diagnostics-next-line preferSchemaOverJson:off
1116 Effect.succeed(output(JSON.stringify({ username: "bilal" }))),
1117 );
1118 mockedExecute.mockReturnValueOnce(
1119 Effect.succeed(
1120 output(
1121 // @effect-diagnostics-next-line preferSchemaOverJson:off
1122 JSON.stringify([
1123 { id: 5, name: "thumbsup", user: { username: "bilal" } },
1124 { id: 6, name: "thumbsup", user: { username: "julius" } },
1125 ]),
1126 ),
1127 ),
1128 );
1129 mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1130 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1131
1132 yield* cli.setReaction({
1133 cwd: "/w",
1134 repository: "acme/web",
1135 number: 7,
1136 content: "thumbs-up",
1137 reacted: false,
1138 });
1139
1140 assert.strictEqual(mockedExecute.mock.calls.length, 3);
1141 expect(argsOfCall(2)).toEqual([
1142 "api",
1143 "projects/acme%2Fweb/merge_requests/7/award_emoji/5",
1144 "--method",
1145 "DELETE",
1146 ]);
1147 }),
1148 );
1149
1150 it.effect("does nothing when the reader has no award of that name to take back", () =>
1151 Effect.gen(function* () {
1152 mockedExecute.mockReturnValueOnce(
1153 // @effect-diagnostics-next-line preferSchemaOverJson:off
1154 Effect.succeed(output(JSON.stringify({ username: "bilal" }))),
1155 );
1156 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1157 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1158
1159 yield* cli.setReaction({
1160 cwd: "/w",
1161 repository: "acme/web",
1162 number: 7,
1163 content: "thumbs-up",
1164 reacted: false,
1165 });
1166
1167 // Nothing to delete: the reaction the caller asked to take back is already gone.
1168 assert.strictEqual(mockedExecute.mock.calls.length, 2);
1169 }),
1170 );
1171
1011 it.effect("names a merge request with no diff revisions rather than calling it unreadable", () =>1172 it.effect("names a merge request with no diff revisions rather than calling it unreadable", () =>
1012 Effect.gen(function* () {1173 Effect.gen(function* () {
1013 mockedExecute.mockReturnValueOnce(1174 mockedExecute.mockReturnValueOnce(
...@@ -1141,4 +1302,99 @@ layer("GitLabPullRequestCli.layer", (it) => {...@@ -1141,4 +1302,99 @@ layer("GitLabPullRequestCli.layer", (it) => {
1141 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] });1302 expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] });
1142 }),1303 }),
1143 );1304 );
1305
1306 it.effect("rewrites a title without touching the description", () =>
1307 Effect.gen(function* () {
1308 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1309 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1310
1311 yield* cli.updateMergeRequest({
1312 cwd: "/w",
1313 repository: "acme/web",
1314 number: 7,
1315 title: "A better title",
1316 });
1317
1318 expect(argsOfCall(0)).toEqual([
1319 "api",
1320 "projects/acme%2Fweb/merge_requests/7",
1321 "--method",
1322 "PUT",
1323 "--input",
1324 "-",
1325 "--header",
1326 "Content-Type: application/json",
1327 ]);
1328 // @effect-diagnostics-next-line preferSchemaOverJson:off
1329 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" });
1330 }),
1331 );
1332
1333 it.effect("sends a rewritten body as GitLab's description, and nothing else", () =>
1334 Effect.gen(function* () {
1335 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1336 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1337
1338 yield* cli.updateMergeRequest({
1339 cwd: "/w",
1340 repository: "acme/web",
1341 number: 7,
1342 description: "What this changes.",
1343 });
1344
1345 // A title sent as an empty string would wipe the one the merge request already has.
1346 // @effect-diagnostics-next-line preferSchemaOverJson:off
1347 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." });
1348 }),
1349 );
1350
1351 it.effect("rewrites title and description together in one request", () =>
1352 Effect.gen(function* () {
1353 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1354 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1355
1356 yield* cli.updateMergeRequest({
1357 cwd: "/w",
1358 repository: "acme/web",
1359 number: 7,
1360 title: "A better title",
1361 description: "What this changes.",
1362 });
1363
1364 assert.strictEqual(mockedExecute.mock.calls.length, 1);
1365 // @effect-diagnostics-next-line preferSchemaOverJson:off
1366 expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({
1367 title: "A better title",
1368 description: "What this changes.",
1369 });
1370 }),
1371 );
1372
1373 it.effect("rewrites a note in place through the note it names", () =>
1374 Effect.gen(function* () {
1375 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
1376 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
1377
1378 yield* cli.updateNote({
1379 cwd: "/w",
1380 repository: "acme/web",
1381 number: 7,
1382 noteId: "42",
1383 body: "true",
1384 });
1385
1386 expect(argsOfCall(0)).toEqual([
1387 "api",
1388 "projects/acme%2Fweb/merge_requests/7/notes/42",
1389 "--method",
1390 "PUT",
1391 "--input",
1392 "-",
1393 "--header",
1394 "Content-Type: application/json",
1395 ]);
1396 // A JSON body, so a note rewritten to a literal `true` stays text.
1397 expect(callAt(0).stdin).toBe('{"body":"true"}');
1398 }),
1399 );
1144});1400});
apps/server/src/pullRequest/GitLabPullRequestCli.ts+241-20
...@@ -11,6 +11,8 @@ import type {...@@ -11,6 +11,8 @@ import type {
11 PullRequestListState,11 PullRequestListState,
12 PullRequestMergeCapabilities,12 PullRequestMergeCapabilities,
13 PullRequestMergeMethod,13 PullRequestMergeMethod,
14 PullRequestReaction,
15 PullRequestReactionContent,
14 PullRequestReviewCommentDraft,16 PullRequestReviewCommentDraft,
15 PullRequestReviewThread,17 PullRequestReviewThread,
16 PullRequestReviewVerdict,18 PullRequestReviewVerdict,
...@@ -19,6 +21,8 @@ import type {...@@ -19,6 +21,8 @@ import type {
1921
20import * as GitLabCli from "../sourceControl/GitLabCli.ts";22import * as GitLabCli from "../sourceControl/GitLabCli.ts";
21import {23import {
24 AWARD_EMOJI_GRAPHQL_QUERY,
25 decodeAwardEmojiJson,
22 decodeCommitDiffRefsJson,26 decodeCommitDiffRefsJson,
23 decodeCommitsJson,27 decodeCommitsJson,
24 decodeDiffRefsJson,28 decodeDiffRefsJson,
...@@ -27,9 +31,11 @@ import {...@@ -27,9 +31,11 @@ import {
27 decodeMergeRequestDiffsJson,31 decodeMergeRequestDiffsJson,
28 decodeMergeRequestListJson,32 decodeMergeRequestListJson,
29 decodeNotesJson,33 decodeNotesJson,
34 decodeOwnAwardIdJson,
30 decodeProjectMergeCapabilitiesJson,35 decodeProjectMergeCapabilitiesJson,
31 decodeProjectUsersJson,36 decodeProjectUsersJson,
32 decodeViewerJson,37 decodeViewerJson,
38 gitLabAwardName,
33 type GitLabDiffRefs,39 type GitLabDiffRefs,
34 type GitLabMergeRequestDetail,40 type GitLabMergeRequestDetail,
35 type GitLabMergeRequestListItem,41 type GitLabMergeRequestListItem,
...@@ -303,6 +309,15 @@ export class GitLabPullRequestCli extends Context.Service<...@@ -303,6 +309,15 @@ export class GitLabPullRequestCli extends Context.Service<
303 readonly mergeMethod?: PullRequestMergeMethod;309 readonly mergeMethod?: PullRequestMergeMethod;
304 }) => Effect.Effect<void, GitLabPullRequestCliError>;310 }) => Effect.Effect<void, GitLabPullRequestCliError>;
305311
312 /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */
313 readonly updateMergeRequest: (input: {
314 readonly cwd: string;
315 readonly repository: string;
316 readonly number: number;
317 readonly title?: string | undefined;
318 readonly description?: string | undefined;
319 }) => Effect.Effect<void, GitLabPullRequestCliError>;
320
306 readonly commentOnMergeRequest: (input: {321 readonly commentOnMergeRequest: (input: {
307 readonly cwd: string;322 readonly cwd: string;
308 readonly repository: string;323 readonly repository: string;
...@@ -310,6 +325,14 @@ export class GitLabPullRequestCli extends Context.Service<...@@ -310,6 +325,14 @@ export class GitLabPullRequestCli extends Context.Service<
310 readonly body: string;325 readonly body: string;
311 }) => Effect.Effect<void, GitLabPullRequestCliError>;326 }) => Effect.Effect<void, GitLabPullRequestCliError>;
312327
328 readonly updateNote: (input: {
329 readonly cwd: string;
330 readonly repository: string;
331 readonly number: number;
332 readonly noteId: string;
333 readonly body: string;
334 }) => Effect.Effect<void, GitLabPullRequestCliError>;
335
313 readonly listDiscussions: (input: {336 readonly listDiscussions: (input: {
314 readonly cwd: string;337 readonly cwd: string;
315 readonly repository: string;338 readonly repository: string;
...@@ -343,6 +366,32 @@ export class GitLabPullRequestCli extends Context.Service<...@@ -343,6 +366,32 @@ export class GitLabPullRequestCli extends Context.Service<
343 readonly discussionId: string;366 readonly discussionId: string;
344 readonly resolved: boolean;367 readonly resolved: boolean;
345 }) => Effect.Effect<void, GitLabPullRequestCliError>;368 }) => Effect.Effect<void, GitLabPullRequestCliError>;
369
370 /** The awards on the merge request and on every note of it, keyed by the note's REST id. */
371 readonly listReactions: (input: {
372 readonly cwd: string;
373 readonly repository: string;
374 readonly number: number;
375 }) => Effect.Effect<
376 {
377 readonly reactions: ReadonlyArray<PullRequestReaction>;
378 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
379 },
380 GitLabPullRequestCliError
381 >;
382
383 /**
384 * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent
385 * awards the merge request itself, which is where its description's reactions live.
386 */
387 readonly setReaction: (input: {
388 readonly cwd: string;
389 readonly repository: string;
390 readonly number: number;
391 readonly noteId?: string | undefined;
392 readonly content: PullRequestReactionContent;
393 readonly reacted: boolean;
394 }) => Effect.Effect<void, GitLabPullRequestCliError>;
346 }395 }
347>()("t3/pullRequest/GitLabPullRequestCli") {}396>()("t3/pullRequest/GitLabPullRequestCli") {}
348397
...@@ -412,12 +461,30 @@ function actionArgs(...@@ -412,12 +461,30 @@ function actionArgs(
412 ...(mergeMethod === "squash" ? ["--squash"] : []),461 ...(mergeMethod === "squash" ? ["--squash"] : []),
413 ...(mergeMethod === "rebase" ? ["--rebase"] : []),462 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
414 ];463 ];
464 // The same command with the flag the other way up: here the wait is the whole point, so
465 // glab is told to arm the merge rather than talked out of it.
466 case "enable-auto-merge":
467 return [
468 "merge",
469 "--auto-merge=true",
470 "--yes",
471 ...(mergeMethod === "squash" ? ["--squash"] : []),
472 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
473 ];
474 // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.
475 case "disable-auto-merge":
476 return [];
415 case "ready":477 case "ready":
416 return ["update", "--ready"];478 return ["update", "--ready"];
417 case "draft":479 case "draft":
418 return ["update", "--draft"];480 return ["update", "--draft"];
419 case "close":481 case "close":
420 return ["close"];482 return ["close"];
483 // A rebase, because GitLab has no other way to move a branch onto its target: there is no
484 // merge-the-target-in equivalent of GitHub's update button, which is why this host declares
485 // `rebase` alone and never has to read the method it was handed.
486 case "update-branch":
487 return ["rebase"];
421 case "reopen":488 case "reopen":
422 return ["reopen"];489 return ["reopen"];
423 }490 }
...@@ -819,7 +886,11 @@ export const make = Effect.gen(function* () {...@@ -819,7 +886,11 @@ export const make = Effect.gen(function* () {
819 }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>886 }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>
820 api({887 api({
821 cwd: input.cwd,888 cwd: input.cwd,
822 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,889 // How far behind the target branch this one is comes only when asked for by name, and it
890 // is asked for here rather than on a second read because it is the same merge request.
891 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([
892 ["include_diverged_commits_count", "true"],
893 ])}`,
823 }).pipe(894 }).pipe(
824 Effect.flatMap((result) => {895 Effect.flatMap((result) => {
825 const decoded = decodeMergeRequestDetailJson(result.stdout.trim());896 const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
...@@ -862,26 +933,104 @@ export const make = Effect.gen(function* () {...@@ -862,26 +933,104 @@ export const make = Effect.gen(function* () {
862 }),933 }),
863 );934 );
864935
936 /** Where an award is written: a note of the merge request, or the merge request itself. */
937 const awardSubjectPath = (input: {
938 readonly repository: string;
939 readonly number: number;
940 readonly noteId?: string | undefined;
941 }) => {
942 const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`;
943 return input.noteId === undefined
944 ? `${mergeRequest}/award_emoji`
945 : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`;
946 };
947
948 /**
949 * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same
950 * count as the conversation itself: awards past the notes that were read belong to notes the
951 * page is not showing.
952 */
953 const awardsPage = (input: {
954 readonly cwd: string;
955 readonly repository: string;
956 readonly number: number;
957 readonly cursor: string | null;
958 readonly page: number;
959 readonly collected: {
960 readonly reactions: ReadonlyArray<PullRequestReaction>;
961 readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>;
962 } | null;
963 }): Effect.Effect<
964 {
965 readonly reactions: ReadonlyArray<PullRequestReaction>;
966 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
967 },
968 GitLabPullRequestCliError
969 > =>
970 api({
971 cwd: input.cwd,
972 path: "graphql",
973 method: "POST",
974 stdin: JSON.stringify({
975 query: AWARD_EMOJI_GRAPHQL_QUERY,
976 variables: {
977 fullPath: input.repository,
978 iid: String(input.number),
979 cursor: input.cursor,
980 },
981 }),
982 }).pipe(
983 Effect.flatMap((result) => {
984 const decoded = decodeAwardEmojiJson(result.stdout.trim());
985 if (!Result.isSuccess(decoded)) {
986 return Effect.fail(
987 new GitLabMergeRequestReadError({
988 command: "glab",
989 cwd: input.cwd,
990 operation: "listReactions",
991 cause: decoded.failure,
992 }),
993 );
994 }
995 const collected = input.collected ?? {
996 reactions: decoded.success.reactions,
997 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
998 };
999 for (const [id, reactions] of decoded.success.reactionsByNoteId)
1000 collected.reactionsByNoteId.set(id, reactions);
1001 return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES
1002 ? Effect.succeed(collected)
1003 : awardsPage({
1004 ...input,
1005 cursor: decoded.success.nextCursor,
1006 page: input.page + 1,
1007 collected,
1008 });
1009 }),
1010 );
1011
1012 const viewerUsername = (input: { readonly cwd: string }) =>
1013 api({ cwd: input.cwd, path: "user" }).pipe(
1014 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
1015 const decoded = decodeViewerJson(result.stdout.trim());
1016 if (!Result.isSuccess(decoded)) {
1017 return Effect.fail(
1018 new GitLabMergeRequestReadError({
1019 command: "glab",
1020 cwd: input.cwd,
1021 operation: "getViewerUsername",
1022 cause: decoded.failure,
1023 }),
1024 );
1025 }
1026 return decoded.success === null
1027 ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
1028 : Effect.succeed(decoded.success);
1029 }),
1030 );
1031
865 return GitLabPullRequestCli.of({1032 return GitLabPullRequestCli.of({
866 getViewerUsername: (input) =>1033 getViewerUsername: viewerUsername,
867 api({ cwd: input.cwd, path: "user" }).pipe(
868 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
869 const decoded = decodeViewerJson(result.stdout.trim());
870 if (!Result.isSuccess(decoded)) {
871 return Effect.fail(
872 new GitLabMergeRequestReadError({
873 command: "glab",
874 cwd: input.cwd,
875 operation: "getViewerUsername",
876 cause: decoded.failure,
877 }),
878 );
879 }
880 return decoded.success === null
881 ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
882 : Effect.succeed(decoded.success);
883 }),
884 ),
8851034
886 listMergeRequests: (input) => {1035 listMergeRequests: (input) => {
887 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);1036 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
...@@ -893,6 +1042,44 @@ export const make = Effect.gen(function* () {...@@ -893,6 +1042,44 @@ export const make = Effect.gen(function* () {
8931042
894 listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),1043 listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),
8951044
1045 listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),
1046
1047 setReaction: (input) =>
1048 Effect.gen(function* () {
1049 const subject = awardSubjectPath(input);
1050 if (input.reacted) {
1051 yield* api({
1052 cwd: input.cwd,
1053 path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`,
1054 method: "POST",
1055 });
1056 return;
1057 }
1058 // GitLab deletes an award by its id and takes no emoji name there, so the reader's own
1059 // award of that name is looked up first. Nothing to delete is success: the reaction the
1060 // caller asked to take back is already gone.
1061 const viewer = yield* viewerUsername({ cwd: input.cwd });
1062 const listed = yield* api({ cwd: input.cwd, path: subject });
1063 const own = decodeOwnAwardIdJson(listed.stdout.trim(), {
1064 content: input.content,
1065 viewer,
1066 });
1067 if (!Result.isSuccess(own)) {
1068 return yield* new GitLabMergeRequestReadError({
1069 command: "glab",
1070 cwd: input.cwd,
1071 operation: "setReaction",
1072 cause: own.failure,
1073 });
1074 }
1075 if (own.success === null) return;
1076 yield* api({
1077 cwd: input.cwd,
1078 path: `${subject}/${own.success}`,
1079 method: "DELETE",
1080 });
1081 }),
1082
896 listCommits: (input) =>1083 listCommits: (input) =>
897 api({1084 api({
898 cwd: input.cwd,1085 cwd: input.cwd,
...@@ -1053,6 +1240,16 @@ export const make = Effect.gen(function* () {...@@ -1053,6 +1240,16 @@ export const make = Effect.gen(function* () {
1053 ),1240 ),
10541241
1055 runMergeRequestAction: (input) => {1242 runMergeRequestAction: (input) => {
1243 // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has
1244 // no flag for is asked of GitLab directly through the same `api` passthrough the rest of
1245 // this module writes with.
1246 if (input.action === "disable-auto-merge") {
1247 return api({
1248 cwd: input.cwd,
1249 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`,
1250 method: "POST",
1251 }).pipe(Effect.asVoid);
1252 }
1056 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);1253 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);
1057 return gitlab1254 return gitlab
1058 .execute({1255 .execute({
...@@ -1062,6 +1259,20 @@ export const make = Effect.gen(function* () {...@@ -1062,6 +1259,20 @@ export const make = Effect.gen(function* () {
1062 .pipe(Effect.asVoid);1259 .pipe(Effect.asVoid);
1063 },1260 },
10641261
1262 updateMergeRequest: (input) =>
1263 api({
1264 cwd: input.cwd,
1265 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1266 method: "PUT",
1267 // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and
1268 // clears what it is sent empty — so a title corrected on its own must carry no
1269 // description at all.
1270 stdin: JSON.stringify({
1271 ...(input.title === undefined ? {} : { title: input.title }),
1272 ...(input.description === undefined ? {} : { description: input.description }),
1273 }),
1274 }).pipe(Effect.asVoid),
1275
1065 commentOnMergeRequest: (input) =>1276 commentOnMergeRequest: (input) =>
1066 api({1277 api({
1067 cwd: input.cwd,1278 cwd: input.cwd,
...@@ -1072,6 +1283,16 @@ export const make = Effect.gen(function* () {...@@ -1072,6 +1283,16 @@ export const make = Effect.gen(function* () {
1072 stdin: JSON.stringify({ body: input.body }),1283 stdin: JSON.stringify({ body: input.body }),
1073 }).pipe(Effect.asVoid),1284 }).pipe(Effect.asVoid),
10741285
1286 updateNote: (input) =>
1287 api({
1288 cwd: input.cwd,
1289 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent(
1290 input.noteId,
1291 )}`,
1292 method: "PUT",
1293 stdin: JSON.stringify({ body: input.body }),
1294 }).pipe(Effect.asVoid),
1295
1075 listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),1296 listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),
10761297
1077 submitReview: (input) =>1298 submitReview: (input) =>
apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts+161-4
...@@ -1,20 +1,36 @@...@@ -1,20 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
24
3import { gitLabViewerPermissions } from "./GitLabPullRequestProvider.ts";5import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
6import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts";
47
5describe("gitLabViewerPermissions", () => {8describe("gitLabViewerPermissions", () => {
6 it("offers everything to a viewer GitLab says can merge", () => {9 it("offers everything to a viewer GitLab says can merge", () => {
7 expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({10 expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({
8 actions: ["merge", "ready", "draft", "close", "reopen"],11 // Arming a merge for later and taking the arming back answer to the same `can_merge`.
12 actions: [
13 "merge",
14 "ready",
15 "draft",
16 "close",
17 "reopen",
18 "update-branch",
19 "enable-auto-merge",
20 "disable-auto-merge",
21 ],
9 comment: true,22 comment: true,
10 resolve: true,23 resolve: true,
11 verdicts: ["comment", "approve"],24 verdicts: ["comment", "approve"],
12 // GitLab says nothing about who may set a reviewer, and an unreported permission is granted.25 // GitLab says nothing about who may set a reviewer, and an unreported permission is granted.
13 requestReviewers: true,26 requestReviewers: true,
27 // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so
28 // offering the choice would be offering something no request could carry out.
29 updateMethods: ["rebase"],
14 });30 });
15 });31 });
1632
17 it("keeps merge from a viewer GitLab says cannot", () => {33 it("keeps merge, now and later, from a viewer GitLab says cannot", () => {
18 // `user.can_merge` already accounts for the role, the approval rules and a protected target34 // `user.can_merge` already accounts for the role, the approval rules and a protected target
19 // branch, so it is the one answer here that does not have to be inferred.35 // branch, so it is the one answer here that does not have to be inferred.
20 expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({36 expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({
...@@ -26,6 +42,12 @@ describe("gitLabViewerPermissions", () => {...@@ -26,6 +42,12 @@ describe("gitLabViewerPermissions", () => {
26 });42 });
27 });43 });
2844
45 it("names no way of updating a branch it will not let this viewer update", () => {
46 // The action and the strategy behind it go together: a button offered with nothing to press
47 // it with, or a strategy left standing next to a withheld button, is a half-refusal.
48 expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined();
49 });
50
29 it("treats an author with read access as any other reader, which is all GitLab says", () => {51 it("treats an author with read access as any other reader, which is all GitLab says", () => {
30 // Its REST API names no relationship between the viewer and the merge request beyond52 // Its REST API names no relationship between the viewer and the merge request beyond
31 // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken53 // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken
...@@ -38,3 +60,138 @@ describe("gitLabViewerPermissions", () => {...@@ -38,3 +60,138 @@ describe("gitLabViewerPermissions", () => {
38 ]);60 ]);
39 });61 });
40});62});
63
64describe("getChangeRequest base freshness", () => {
65 const detail = {
66 number: 7,
67 title: "Merge request 7",
68 url: "https://gitlab.com/acme/web/-/merge_requests/7",
69 author: null,
70 headBranch: "feat/page",
71 baseBranch: "main",
72 state: "open" as const,
73 isDraft: false,
74 mergeability: "mergeable" as const,
75 additions: 0,
76 deletions: 0,
77 createdAt: "2026-07-01T00:00:00Z",
78 updatedAt: "2026-07-02T00:00:00Z",
79 reviewRequestLogins: [],
80 labels: [],
81 body: "",
82 changedFiles: 1,
83 mergedAt: null,
84 closedAt: null,
85 reviewers: [],
86 checks: [],
87 viewerCanMerge: true,
88 reviewerIds: [],
89 };
90
91 const readWith = (divergence: { readonly divergedCommits?: number }) =>
92 Effect.gen(function* () {
93 const provider = yield* make;
94 return yield* provider.getChangeRequest({
95 cwd: "/w",
96 repository: "acme/web",
97 host: "gitlab.com",
98 number: 7,
99 });
100 }).pipe(
101 Effect.provide(
102 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
103 getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }),
104 getProjectMergeCapabilities: () =>
105 Effect.succeed({ merge: true, squash: true, rebase: true }),
106 }),
107 ),
108 );
109
110 it.effect("reads a counted divergence as a branch that has fallen behind", () =>
111 Effect.gen(function* () {
112 const changeRequest = yield* readWith({ divergedCommits: 3 });
113
114 expect(changeRequest.baseComparison).toBe("behind");
115 expect(changeRequest.behindBy).toBe(3);
116 }),
117 );
118
119 it.effect("reads a divergence of none as a branch that is current", () =>
120 Effect.gen(function* () {
121 const changeRequest = yield* readWith({ divergedCommits: 0 });
122
123 expect(changeRequest.baseComparison).toBe("up-to-date");
124 expect(changeRequest.behindBy).toBe(0);
125 }),
126 );
127
128 it.effect("says nothing at all where GitLab counted nothing", () =>
129 Effect.gen(function* () {
130 // An install too old to answer has to leave the page silent rather than let it claim the
131 // branch is current, which is the one wrong thing this banner could say.
132 const changeRequest = yield* readWith({});
133
134 expect(changeRequest.baseComparison).toBe("unknown");
135 expect(changeRequest.behindBy).toBeUndefined();
136 }),
137 );
138});
139
140describe("rewriting what has already been said", () => {
141 const updateMergeRequest = vi.fn(() => Effect.void);
142 const updateNote = vi.fn(() => Effect.void);
143
144 const providerWith = make.pipe(
145 Effect.provide(
146 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }),
147 ),
148 );
149
150 it.effect("sends only the half of the merge request the reader rewrote", () =>
151 Effect.gen(function* () {
152 const provider = yield* providerWith;
153 assert.isDefined(provider.updateChangeRequest);
154
155 yield* provider.updateChangeRequest({
156 cwd: "/w",
157 repository: "acme/web",
158 host: "gitlab.com",
159 number: 7,
160 body: "What this changes.",
161 });
162
163 // GitLab calls it the description, and the title stays out of the request entirely.
164 expect(updateMergeRequest).toHaveBeenCalledWith({
165 cwd: "/w",
166 repository: "acme/web",
167 number: 7,
168 description: "What this changes.",
169 });
170 }),
171 );
172
173 it.effect("rewrites a positioned comment through the same note as any other", () =>
174 Effect.gen(function* () {
175 const provider = yield* providerWith;
176 assert.isDefined(provider.updateComment);
177
178 yield* provider.updateComment({
179 cwd: "/w",
180 repository: "acme/web",
181 host: "gitlab.com",
182 number: 7,
183 commentId: "42",
184 kind: "review-comment",
185 body: "Reworded.",
186 });
187
188 expect(updateNote).toHaveBeenCalledWith({
189 cwd: "/w",
190 repository: "acme/web",
191 number: 7,
192 noteId: "42",
193 body: "Reworded.",
194 });
195 }),
196 );
197});
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+108-7
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";2import type {
3 PullRequestCapabilities,
4 PullRequestReaction,
5 PullRequestViewerPermissions,
6} from "@t3tools/contracts";
37
4import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";8import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
5import {9import {
...@@ -12,10 +16,24 @@ import {...@@ -12,10 +16,24 @@ import {
12const CAPABILITIES: PullRequestCapabilities = {16const CAPABILITIES: PullRequestCapabilities = {
13 diff: true,17 diff: true,
14 comment: true,18 comment: true,
15 actions: ["merge", "ready", "draft", "close", "reopen"],19 actions: [
20 "merge",
21 "ready",
22 "draft",
23 "close",
24 "reopen",
25 "update-branch",
26 "enable-auto-merge",
27 "disable-auto-merge",
28 ],
16 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.29 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
17 mergeMethods: ["merge", "squash", "rebase"],30 mergeMethods: ["merge", "squash", "rebase"],
31 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing
32 // that merges the target back in the way GitHub's update button can. Declaring only what it
33 // does is what lets a request to merge the target in be refused instead of quietly rebasing.
34 updateMethods: ["rebase"],
18 search: true,35 search: true,
36 reactions: true,
19 review: {37 review: {
20 inlineComment: true,38 inlineComment: true,
21 reply: true,39 reply: true,
...@@ -25,8 +43,21 @@ const CAPABILITIES: PullRequestCapabilities = {...@@ -25,8 +43,21 @@ const CAPABILITIES: PullRequestCapabilities = {
25 verdicts: ["comment", "approve"],43 verdicts: ["comment", "approve"],
26 },44 },
27 reviewers: { request: true, listCandidates: true },45 reviewers: { request: true, listCandidates: true },
46 edit: { changeRequest: true, comment: true },
28};47};
2948
49/**
50 * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to
51 * the target, so it is not literally the same permission — but GitLab reports nothing narrower,
52 * and someone it will not let land this change has no business rewriting its branch either.
53 */
54const MERGE_ACTIONS: ReadonlySet<string> = new Set([
55 "merge",
56 "update-branch",
57 "enable-auto-merge",
58 "disable-auto-merge",
59]);
60
30/**61/**
31 * What the signed-in account may do here. GitLab answers exactly one of these questions per62 * What the signed-in account may do here. GitLab answers exactly one of these questions per
32 * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing63 * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing
...@@ -45,11 +76,16 @@ export function gitLabViewerPermissions(input: {...@@ -45,11 +76,16 @@ export function gitLabViewerPermissions(input: {
45 readonly viewerCanMerge: boolean;76 readonly viewerCanMerge: boolean;
46}): PullRequestViewerPermissions {77}): PullRequestViewerPermissions {
47 return {78 return {
48 actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.viewerCanMerge),79 // Arming the merge and taking the arming back are the merge, deferred, so they answer to
80 // the same `can_merge` the merge itself does.
81 actions: CAPABILITIES.actions.filter(
82 (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge,
83 ),
49 comment: true,84 comment: true,
50 resolve: true,85 resolve: true,
51 verdicts: CAPABILITIES.review.verdicts,86 verdicts: CAPABILITIES.review.verdicts,
52 requestReviewers: true,87 requestReviewers: true,
88 ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}),
53 };89 };
54}90}
5591
...@@ -114,6 +150,17 @@ export const make = Effect.gen(function* () {...@@ -114,6 +150,17 @@ export const make = Effect.gen(function* () {
114 ...mergeRequest,150 ...mergeRequest,
115 mergeCapabilities,151 mergeCapabilities,
116 viewerPermissions: gitLabViewerPermissions(mergeRequest),152 viewerPermissions: gitLabViewerPermissions(mergeRequest),
153 // A GitLab too old to count the divergence says nothing here rather than "up to
154 // date": the banner is worth missing, and a wrong all-clear is not worth showing.
155 baseComparison:
156 mergeRequest.divergedCommits === undefined
157 ? "unknown"
158 : mergeRequest.divergedCommits > 0
159 ? "behind"
160 : "up-to-date",
161 ...(mergeRequest.divergedCommits === undefined
162 ? {}
163 : { behindBy: mergeRequest.divergedCommits }),
117 }),164 }),
118 ),165 ),
119 ),166 ),
...@@ -128,19 +175,37 @@ export const make = Effect.gen(function* () {...@@ -128,19 +175,37 @@ export const make = Effect.gen(function* () {
128 cli175 cli
129 .listDiscussions(input)176 .listDiscussions(input)
130 .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),177 .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),
178 // The notes endpoint carries no award of any kind, so they are read alongside it. A
179 // failed read costs the conversation its reactions rather than its words.
180 cli.listReactions(input).pipe(
181 Effect.orElseSucceed(() => ({
182 reactions: [] as ReadonlyArray<PullRequestReaction>,
183 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
184 })),
185 ),
131 ],186 ],
132 { concurrency: 3 },187 { concurrency: 4 },
133 ).pipe(188 ).pipe(
134 Effect.mapError(fail("getChangeRequestActivity")),189 Effect.mapError(fail("getChangeRequestActivity")),
135 Effect.map(190 Effect.map(
136 ([notes, commits, discussions]): ProviderChangeRequestActivity => ({191 ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({
137 comments: notes.comments,192 reactions: awards.reactions,
193 comments: notes.comments.map((comment) => ({
194 ...comment,
195 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
196 })),
138 // GitLab reports no count of its own, so the walk's own total is the host's: the197 // GitLab reports no count of its own, so the walk's own total is the host's: the
139 // notes endpoint carries every comment on the merge request, including the ones198 // notes endpoint carries every comment on the merge request, including the ones
140 // written under a discussion, and it is read until GitLab runs out.199 // written under a discussion, and it is read until GitLab runs out.
141 commentCount: notes.comments.length,200 commentCount: notes.comments.length,
142 commentsTruncated: notes.truncated || discussions.truncated,201 commentsTruncated: notes.truncated || discussions.truncated,
143 reviewThreads: discussions.threads,202 reviewThreads: discussions.threads.map((thread) => ({
203 ...thread,
204 comments: thread.comments.map((comment) => ({
205 ...comment,
206 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
207 })),
208 })),
144 commits,209 commits,
145 }),210 }),
146 ),211 ),
...@@ -188,8 +253,32 @@ export const make = Effect.gen(function* () {...@@ -188,8 +253,32 @@ export const make = Effect.gen(function* () {
188 })253 })
189 .pipe(Effect.mapError(fail("runAction"))),254 .pipe(Effect.mapError(fail("runAction"))),
190255
256 updateChangeRequest: (input) =>
257 cli
258 .updateMergeRequest({
259 cwd: input.cwd,
260 repository: input.repository,
261 number: input.number,
262 ...(input.title === undefined ? {} : { title: input.title }),
263 ...(input.body === undefined ? {} : { description: input.body }),
264 })
265 .pipe(Effect.mapError(fail("updateChangeRequest"))),
266
191 comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),267 comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),
192268
269 // The kind is not read: every comment this provider hands out, positioned or not, carries a
270 // plain REST note id, and one endpoint rewrites both.
271 updateComment: (input) =>
272 cli
273 .updateNote({
274 cwd: input.cwd,
275 repository: input.repository,
276 number: input.number,
277 noteId: input.commentId,
278 body: input.body,
279 })
280 .pipe(Effect.mapError(fail("updateComment"))),
281
193 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),282 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
194283
195 replyToThread: (input) =>284 replyToThread: (input) =>
...@@ -203,6 +292,18 @@ export const make = Effect.gen(function* () {...@@ -203,6 +292,18 @@ export const make = Effect.gen(function* () {
203 })292 })
204 .pipe(Effect.mapError(fail("replyToThread"))),293 .pipe(Effect.mapError(fail("replyToThread"))),
205294
295 setReaction: (input) =>
296 cli
297 .setReaction({
298 cwd: input.cwd,
299 repository: input.repository,
300 number: input.number,
301 ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }),
302 content: input.content,
303 reacted: input.reacted,
304 })
305 .pipe(Effect.mapError(fail("setReaction"))),
306
206 setThreadResolution: (input) =>307 setThreadResolution: (input) =>
207 cli308 cli
208 .setDiscussionResolution({309 .setDiscussionResolution({
apps/server/src/pullRequest/PullRequestProvider.ts+77
...@@ -3,22 +3,30 @@ import * as Schema from "effect/Schema";...@@ -3,22 +3,30 @@ import * as Schema from "effect/Schema";
3import type {3import type {
4 PullRequestAction,4 PullRequestAction,
5 PullRequestActor,5 PullRequestActor,
6 PullRequestBaseComparison,
6 PullRequestCapabilities,7 PullRequestCapabilities,
8 PullRequestChecksState,
7 PullRequestCheck,9 PullRequestCheck,
8 PullRequestComment,10 PullRequestComment,
9 PullRequestCommit,11 PullRequestCommit,
10 PullRequestInvolvement,12 PullRequestInvolvement,
11 PullRequestLabel,13 PullRequestLabel,
14 PullRequestListFilters,
12 PullRequestListState,15 PullRequestListState,
13 PullRequestMergeCapabilities,16 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,17 PullRequestMergeMethod,
15 PullRequestMergeability,18 PullRequestMergeability,
19 PullRequestOmittedFileStat,
20 PullRequestReaction,
21 PullRequestReactionContent,
16 PullRequestReviewCommentDraft,22 PullRequestReviewCommentDraft,
23 PullRequestReviewDecision,
17 PullRequestReviewThread,24 PullRequestReviewThread,
18 PullRequestReviewVerdict,25 PullRequestReviewVerdict,
19 PullRequestReviewerCandidateList,26 PullRequestReviewerCandidateList,
20 PullRequestReviewerKind,27 PullRequestReviewerKind,
21 PullRequestState,28 PullRequestState,
29 PullRequestUpdateMethod,
22 PullRequestViewerPermissions,30 PullRequestViewerPermissions,
23 SourceControlProviderKind,31 SourceControlProviderKind,
24} from "@t3tools/contracts";32} from "@t3tools/contracts";
...@@ -64,6 +72,10 @@ export interface ProviderChangeRequest {...@@ -64,6 +72,10 @@ export interface ProviderChangeRequest {
64 /** Accounts with a review requested. Team-level requests are excluded by each provider. */72 /** Accounts with a review requested. Team-level requests are excluded by each provider. */
65 readonly reviewRequestLogins: ReadonlyArray<string>;73 readonly reviewRequestLogins: ReadonlyArray<string>;
66 readonly labels: ReadonlyArray<PullRequestLabel>;74 readonly labels: ReadonlyArray<PullRequestLabel>;
75 /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */
76 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
77 /** Absent from a host that reports no check rollup on its listings. */
78 readonly checksState?: PullRequestChecksState | null | undefined;
67}79}
6880
69export interface ProviderChangeRequestPage {81export interface ProviderChangeRequestPage {
...@@ -140,6 +152,11 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest {...@@ -140,6 +152,11 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
140 readonly checks: ReadonlyArray<PullRequestCheck>;152 readonly checks: ReadonlyArray<PullRequestCheck>;
141 readonly mergeCapabilities: PullRequestMergeCapabilities;153 readonly mergeCapabilities: PullRequestMergeCapabilities;
142 readonly viewerPermissions: PullRequestViewerPermissions;154 readonly viewerPermissions: PullRequestViewerPermissions;
155 /** Absent from a host that cannot compare the branch with its base, which is most of them. */
156 readonly baseComparison?: PullRequestBaseComparison;
157 readonly behindBy?: number;
158 /** Absent from a host that does not report whether it is armed to merge this on its own. */
159 readonly autoMergeEnabled?: boolean;
143}160}
144161
145/** The conversation-shaped half of a detail, loaded after the core can already render. */162/** The conversation-shaped half of a detail, loaded after the core can already render. */
...@@ -158,6 +175,8 @@ export interface ProviderChangeRequestActivity {...@@ -158,6 +175,8 @@ export interface ProviderChangeRequestActivity {
158 readonly commentsTruncated: boolean;175 readonly commentsTruncated: boolean;
159 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;176 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
160 readonly commits: ReadonlyArray<PullRequestCommit>;177 readonly commits: ReadonlyArray<PullRequestCommit>;
178 /** The change request's own reactions, from a host that has them. */
179 readonly reactions?: ReadonlyArray<PullRequestReaction>;
161}180}
162181
163export interface ProviderDiffSlice {182export interface ProviderDiffSlice {
...@@ -165,6 +184,8 @@ export interface ProviderDiffSlice {...@@ -165,6 +184,8 @@ export interface ProviderDiffSlice {
165 /** Something in this slice could not be shown, as opposed to there being more slices. */184 /** Something in this slice could not be shown, as opposed to there being more slices. */
166 readonly truncated: boolean;185 readonly truncated: boolean;
167 readonly nextCursor: string | null;186 readonly nextCursor: string | null;
187 /** The host's own counts for the files whose hunks it withheld from this slice. */
188 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
168}189}
169190
170export interface ProviderDiffFileContents {191export interface ProviderDiffFileContents {
...@@ -216,6 +237,12 @@ export interface PullRequestProviderApi {...@@ -216,6 +237,12 @@ export interface PullRequestProviderApi {
216 * asks for the first slice, which is every listing that has not been continued.237 * asks for the first slice, which is every listing that has not been continued.
217 */238 */
218 readonly cursor?: ProviderListCursor | undefined;239 readonly cursor?: ProviderListCursor | undefined;
240 /**
241 * Further narrowings, which a host applies as far as it can and ignores the rest of —
242 * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows
243 * what it gets for the fields a row carries.
244 */
245 readonly filters?: PullRequestListFilters | undefined;
219 },246 },
220 ) => Effect.Effect<ProviderChangeRequestPage, PullRequestProviderError>;247 ) => Effect.Effect<ProviderChangeRequestPage, PullRequestProviderError>;
221248
...@@ -244,6 +271,7 @@ export interface PullRequestProviderApi {...@@ -244,6 +271,7 @@ export interface PullRequestProviderApi {
244 readonly limit: number;271 readonly limit: number;
245 readonly query?: string | undefined;272 readonly query?: string | undefined;
246 readonly cursor?: ProviderListCursor | undefined;273 readonly cursor?: ProviderListCursor | undefined;
274 readonly filters?: PullRequestListFilters | undefined;
247 }) => Effect.Effect<ProviderBatchedChangeRequestPage, PullRequestProviderError>;275 }) => Effect.Effect<ProviderBatchedChangeRequestPage, PullRequestProviderError>;
248276
249 /**277 /**
...@@ -314,7 +342,23 @@ export interface PullRequestProviderApi {...@@ -314,7 +342,23 @@ export interface PullRequestProviderApi {
314 input: ProviderRepositoryRef & {342 input: ProviderRepositoryRef & {
315 readonly number: number;343 readonly number: number;
316 readonly action: PullRequestAction;344 readonly action: PullRequestAction;
345 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
317 readonly mergeMethod?: PullRequestMergeMethod;346 readonly mergeMethod?: PullRequestMergeMethod;
347 /** Only meaningful for `update-branch`; absent takes the host's own default. */
348 readonly updateMethod?: PullRequestUpdateMethod;
349 },
350 ) => Effect.Effect<void, PullRequestProviderError>;
351
352 /**
353 * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest`
354 * is true, and never with both fields absent — the caller refuses that before it gets here,
355 * because a host asked to change nothing answers differently on each of them.
356 */
357 readonly updateChangeRequest?: (
358 input: ProviderRepositoryRef & {
359 readonly number: number;
360 readonly title?: string | undefined;
361 readonly body?: string | undefined;
318 },362 },
319 ) => Effect.Effect<void, PullRequestProviderError>;363 ) => Effect.Effect<void, PullRequestProviderError>;
320364
...@@ -322,6 +366,23 @@ export interface PullRequestProviderApi {...@@ -322,6 +366,23 @@ export interface PullRequestProviderApi {
322 input: ProviderRepositoryRef & { readonly number: number; readonly body: string },366 input: ProviderRepositoryRef & { readonly number: number; readonly body: string },
323 ) => Effect.Effect<void, PullRequestProviderError>;367 ) => Effect.Effect<void, PullRequestProviderError>;
324368
369 /**
370 * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is
371 * true, with an id exactly as the conversation carried it.
372 *
373 * Whether this remark is the reader's to rewrite is the host's own answer: no read here can
374 * settle it, since access can be taken away between the conversation being read and the
375 * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so.
376 */
377 readonly updateComment?: (
378 input: ProviderRepositoryRef & {
379 readonly number: number;
380 readonly commentId: string;
381 readonly kind: "issue-comment" | "review-comment";
382 readonly body: string;
383 },
384 ) => Effect.Effect<void, PullRequestProviderError>;
385
325 /**386 /**
326 * Sends a whole review at once. Only called for a verdict the host declared in387 * Sends a whole review at once. Only called for a verdict the host declared in
327 * `capabilities.review.verdicts`, and with line comments only where it declared388 * `capabilities.review.verdicts`, and with line comments only where it declared
...@@ -376,6 +437,22 @@ export interface PullRequestProviderApi {...@@ -376,6 +437,22 @@ export interface PullRequestProviderApi {
376 },437 },
377 ) => Effect.Effect<void, PullRequestProviderError>;438 ) => Effect.Effect<void, PullRequestProviderError>;
378439
440 /**
441 * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true.
442 *
443 * `subjectId` is a remark's id as the conversation carried it; absent means the change request
444 * itself, whose reactions sit on its description. Whatever a host needs to address either of
445 * them is worked out here, because the id a conversation travels with is the one the reader has.
446 */
447 readonly setReaction: (
448 input: ProviderRepositoryRef & {
449 readonly number: number;
450 readonly subjectId?: string | undefined;
451 readonly content: PullRequestReactionContent;
452 readonly reacted: boolean;
453 },
454 ) => Effect.Effect<void, PullRequestProviderError>;
455
379 /** Only called when `capabilities.review.resolve` is true. */456 /** Only called when `capabilities.review.resolve` is true. */
380 readonly setThreadResolution: (457 readonly setThreadResolution: (
381 input: ProviderRepositoryRef & {458 input: ProviderRepositoryRef & {
apps/server/src/pullRequest/PullRequestService.test.ts+923-1
...@@ -114,8 +114,10 @@ function fakeProvider(...@@ -114,8 +114,10 @@ function fakeProvider(
114 actions: ["merge", "ready", "draft", "close", "reopen"],114 actions: ["merge", "ready", "draft", "close", "reopen"],
115 mergeMethods: ["merge"],115 mergeMethods: ["merge"],
116 search: true,116 search: true,
117 reactions: true,
117 review: FULL_REVIEW,118 review: FULL_REVIEW,
118 reviewers: FULL_REVIEWERS,119 reviewers: FULL_REVIEWERS,
120 edit: { changeRequest: true, comment: true },
119 },121 },
120 getViewer: () => Effect.succeed("bilal"),122 getViewer: () => Effect.succeed("bilal"),
121 // A viewer who may do everything the host can, so a test only narrows what it is about.123 // A viewer who may do everything the host can, so a test only narrows what it is about.
...@@ -132,10 +134,13 @@ function fakeProvider(...@@ -132,10 +134,13 @@ function fakeProvider(
132 getChangeRequestActivity: () => Effect.die("unused"),134 getChangeRequestActivity: () => Effect.die("unused"),
133 getDiff: () => Effect.die("unused"),135 getDiff: () => Effect.die("unused"),
134 runAction: () => Effect.void,136 runAction: () => Effect.void,
137 updateChangeRequest: () => Effect.void,
135 comment: () => Effect.void,138 comment: () => Effect.void,
139 updateComment: () => Effect.void,
136 submitReview: () => Effect.void,140 submitReview: () => Effect.void,
137 replyToThread: () => Effect.void,141 replyToThread: () => Effect.void,
138 setThreadResolution: () => Effect.void,142 setThreadResolution: () => Effect.void,
143 setReaction: () => Effect.void,
139 listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }),144 listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }),
140 setReviewerRequest: () => Effect.void,145 setReviewerRequest: () => Effect.void,
141 ...overrides,146 ...overrides,
...@@ -435,8 +440,9 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"...@@ -435,8 +440,9 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows"
435440
436 const result = yield* service.list({ state: "open" });441 const result = yield* service.list({ state: "open" });
437442
443 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
438 assert.deepStrictEqual(result.nextCursors, {444 assert.deepStrictEqual(result.nextCursors, {
439 "dev.azure.com acme/web": "2026-07-02T00:00:00Z|4|7",445 "dev.azure.com web": "2026-07-02T00:00:00Z|4|7",
440 });446 });
441 }),447 }),
442);448);
...@@ -945,6 +951,7 @@ it.effect("refuses an action the host never claimed it could run", () =>...@@ -945,6 +951,7 @@ it.effect("refuses an action the host never claimed it could run", () =>
945 actions: ["merge", "close"],951 actions: ["merge", "close"],
946 mergeMethods: ["merge"],952 mergeMethods: ["merge"],
947 search: true,953 search: true,
954 reactions: true,
948 review: FULL_REVIEW,955 review: FULL_REVIEW,
949 reviewers: FULL_REVIEWERS,956 reviewers: FULL_REVIEWERS,
950 },957 },
...@@ -1007,6 +1014,136 @@ it.effect("refuses an action this viewer may not take, and says what access it t...@@ -1007,6 +1014,136 @@ it.effect("refuses an action this viewer may not take, and says what access it t
1007 }),1014 }),
1008);1015);
10091016
1017it.effect("gates arming a merge for later exactly as it gates merging now", () =>
1018 Effect.gen(function* () {
1019 let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null;
1020 const service = yield* makeService({
1021 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1022 providers: [
1023 fakeProvider("github", {
1024 capabilities: {
1025 diff: true,
1026 comment: true,
1027 actions: ["merge", "close", "enable-auto-merge", "disable-auto-merge"],
1028 mergeMethods: ["merge", "squash"],
1029 search: true,
1030 reactions: true,
1031 review: FULL_REVIEW,
1032 reviewers: FULL_REVIEWERS,
1033 },
1034 // This account may close the change request it opened, and nothing else here.
1035 getViewerPermissions: () =>
1036 Effect.succeed({
1037 actions: ["close"],
1038 comment: true,
1039 resolve: true,
1040 verdicts: ["comment", "approve", "request-changes"],
1041 requestReviewers: false,
1042 }),
1043 runAction: (input) => {
1044 ranWith = {
1045 action: input.action,
1046 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1047 };
1048 return Effect.void;
1049 },
1050 }),
1051 ],
1052 });
1053 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1054
1055 const refused = yield* Effect.flip(
1056 service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }),
1057 );
1058 assert.strictEqual(refused._tag, "PullRequestOperationError");
1059 assert.include(refused.message, "merged for you once it is ready");
1060 assert.strictEqual(ranWith, null);
1061
1062 // The strategy is checked against the host for an armed merge too: a merge it performs
1063 // later is still a merge, and one it cannot spell must not be passed on.
1064 const wrongStrategy = yield* Effect.flip(
1065 service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "rebase" }),
1066 );
1067 assert.strictEqual(wrongStrategy._tag, "PullRequestOperationError");
1068 assert.strictEqual(ranWith, null);
1069 }),
1070);
1071
1072it.effect("hands the host the strategy an armed merge was asked for", () =>
1073 Effect.gen(function* () {
1074 let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null;
1075 const service = yield* makeService({
1076 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1077 providers: [
1078 fakeProvider("github", {
1079 capabilities: {
1080 diff: true,
1081 comment: true,
1082 actions: ["merge", "enable-auto-merge", "disable-auto-merge"],
1083 mergeMethods: ["merge", "squash"],
1084 search: true,
1085 reactions: true,
1086 review: FULL_REVIEW,
1087 reviewers: FULL_REVIEWERS,
1088 },
1089 getViewerPermissions: () =>
1090 Effect.succeed({
1091 actions: ["merge", "enable-auto-merge", "disable-auto-merge"],
1092 comment: true,
1093 resolve: true,
1094 verdicts: ["comment", "approve", "request-changes"],
1095 requestReviewers: true,
1096 }),
1097 runAction: (input) => {
1098 ranWith = {
1099 action: input.action,
1100 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1101 };
1102 return Effect.void;
1103 },
1104 }),
1105 ],
1106 });
1107 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1108
1109 yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" });
1110 assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" });
1111
1112 yield* service.runAction({ ...reference, action: "disable-auto-merge" });
1113 assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" });
1114 }),
1115);
1116
1117it.effect("refuses an auto-merge the host never claimed, without asking it", () =>
1118 Effect.gen(function* () {
1119 let ran = false;
1120 const service = yield* makeService({
1121 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1122 providers: [
1123 // Bitbucket's shape: it merges, and has nothing that merges later on its own.
1124 fakeProvider("github", {
1125 runAction: () => {
1126 ran = true;
1127 return Effect.void;
1128 },
1129 }),
1130 ],
1131 });
1132
1133 const error = yield* Effect.flip(
1134 service.runAction({
1135 projectId: "p1" as ProjectId,
1136 repository: "acme/web",
1137 number: 1,
1138 action: "enable-auto-merge",
1139 }),
1140 );
1141
1142 assert.strictEqual(error._tag, "PullRequestOperationError");
1143 assert.isFalse(ran);
1144 }),
1145);
1146
1010it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () =>1147it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () =>
1011 Effect.gen(function* () {1148 Effect.gen(function* () {
1012 const service = yield* makeService({1149 const service = yield* makeService({
...@@ -1054,6 +1191,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all"...@@ -1054,6 +1191,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all"
1054 actions: ["merge", "close"],1191 actions: ["merge", "close"],
1055 mergeMethods: ["merge"],1192 mergeMethods: ["merge"],
1056 search: true,1193 search: true,
1194 reactions: true,
1057 review: FULL_REVIEW,1195 review: FULL_REVIEW,
1058 reviewers: FULL_REVIEWERS,1196 reviewers: FULL_REVIEWERS,
1059 },1197 },
...@@ -1092,6 +1230,7 @@ it.effect("refuses a comment on a host that cannot post one", () =>...@@ -1092,6 +1230,7 @@ it.effect("refuses a comment on a host that cannot post one", () =>
1092 actions: ["merge"],1230 actions: ["merge"],
1093 mergeMethods: ["merge"],1231 mergeMethods: ["merge"],
1094 search: true,1232 search: true,
1233 reactions: true,
1095 review: FULL_REVIEW,1234 review: FULL_REVIEW,
1096 reviewers: FULL_REVIEWERS,1235 reviewers: FULL_REVIEWERS,
1097 },1236 },
...@@ -1271,6 +1410,7 @@ it.effect("refuses a diff on a host that cannot produce one", () =>...@@ -1271,6 +1410,7 @@ it.effect("refuses a diff on a host that cannot produce one", () =>
1271 actions: ["merge", "close"],1410 actions: ["merge", "close"],
1272 mergeMethods: ["merge"],1411 mergeMethods: ["merge"],
1273 search: true,1412 search: true,
1413 reactions: true,
1274 review: FULL_REVIEW,1414 review: FULL_REVIEW,
1275 reviewers: FULL_REVIEWERS,1415 reviewers: FULL_REVIEWERS,
1276 },1416 },
...@@ -1330,6 +1470,7 @@ it.effect("refuses a verdict the host never claimed, without asking the provider...@@ -1330,6 +1470,7 @@ it.effect("refuses a verdict the host never claimed, without asking the provider
1330 actions: ["merge"],1470 actions: ["merge"],
1331 mergeMethods: ["merge"],1471 mergeMethods: ["merge"],
1332 search: true,1472 search: true,
1473 reactions: true,
1333 // GitLab's shape: it approves, and has nothing that rejects.1474 // GitLab's shape: it approves, and has nothing that rejects.
1334 review: {1475 review: {
1335 inlineComment: true,1476 inlineComment: true,
...@@ -1377,6 +1518,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>...@@ -1377,6 +1518,7 @@ it.effect("refuses line comments on a host that takes only a summary", () =>
1377 actions: ["merge"],1518 actions: ["merge"],
1378 mergeMethods: ["merge"],1519 mergeMethods: ["merge"],
1379 search: true,1520 search: true,
1521 reactions: true,
1380 review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] },1522 review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] },
1381 reviewers: FULL_REVIEWERS,1523 reviewers: FULL_REVIEWERS,
1382 },1524 },
...@@ -1454,6 +1596,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>...@@ -1454,6 +1596,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>
1454 actions: ["merge"],1596 actions: ["merge"],
1455 mergeMethods: ["merge"],1597 mergeMethods: ["merge"],
1456 search: true,1598 search: true,
1599 reactions: true,
1457 review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] },1600 review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] },
1458 reviewers: FULL_REVIEWERS,1601 reviewers: FULL_REVIEWERS,
1459 },1602 },
...@@ -1480,6 +1623,149 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>...@@ -1480,6 +1623,149 @@ it.effect("refuses to resolve a conversation on a host that cannot", () =>
1480 }),1623 }),
1481);1624);
14821625
1626it.effect("refuses to react on a host with no reactions", () =>
1627 Effect.gen(function* () {
1628 const service = yield* makeService({
1629 projects: [
1630 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1631 ],
1632 providers: [
1633 fakeProvider("github", {
1634 capabilities: {
1635 diff: true,
1636 comment: true,
1637 actions: ["merge"],
1638 mergeMethods: ["merge"],
1639 search: true,
1640 reactions: false,
1641 review: FULL_REVIEW,
1642 reviewers: FULL_REVIEWERS,
1643 },
1644 setReaction: () => Effect.die("must not be called"),
1645 }),
1646 ],
1647 });
1648
1649 const error = yield* Effect.flip(
1650 service.setReaction({
1651 projectId: "p1" as ProjectId,
1652 repository: "pingdotgg/t3code",
1653 number: 1,
1654 content: "heart",
1655 reacted: true,
1656 }),
1657 );
1658
1659 assert.strictEqual(error._tag, "PullRequestOperationError");
1660 }),
1661);
1662
1663it.effect("refuses to react on a host whose capabilities omit reactions entirely", () =>
1664 Effect.gen(function* () {
1665 const service = yield* makeService({
1666 projects: [
1667 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1668 ],
1669 providers: [
1670 fakeProvider("github", {
1671 capabilities: {
1672 diff: true,
1673 comment: true,
1674 actions: ["merge"],
1675 mergeMethods: ["merge"],
1676 search: true,
1677 review: FULL_REVIEW,
1678 reviewers: FULL_REVIEWERS,
1679 },
1680 setReaction: () => Effect.die("must not be called"),
1681 }),
1682 ],
1683 });
1684
1685 const error = yield* Effect.flip(
1686 service.setReaction({
1687 projectId: "p1" as ProjectId,
1688 repository: "pingdotgg/t3code",
1689 number: 1,
1690 content: "heart",
1691 reacted: true,
1692 }),
1693 );
1694
1695 assert.strictEqual(error._tag, "PullRequestOperationError");
1696 }),
1697);
1698
1699it.effect("passes a reaction through with its subject id on a host that has them", () =>
1700 Effect.gen(function* () {
1701 let received: {
1702 readonly subjectId: string | undefined;
1703 readonly content: string;
1704 readonly reacted: boolean;
1705 } | null = null;
1706 const service = yield* makeService({
1707 projects: [
1708 project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }),
1709 ],
1710 providers: [
1711 fakeProvider("github", {
1712 setReaction: (input) => {
1713 received = {
1714 subjectId: input.subjectId,
1715 content: input.content,
1716 reacted: input.reacted,
1717 };
1718 return Effect.void;
1719 },
1720 }),
1721 ],
1722 });
1723
1724 yield* service.setReaction({
1725 projectId: "p1" as ProjectId,
1726 repository: "pingdotgg/t3code",
1727 number: 1,
1728 subjectId: "IC_1",
1729 content: "heart",
1730 reacted: true,
1731 });
1732
1733 assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true });
1734 }),
1735);
1736
1737it.effect("invalidates the cached activity after reacting, like the other mutations", () =>
1738 Effect.gen(function* () {
1739 let activityCalls = 0;
1740 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1741 const service = yield* makeService({
1742 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1743 providers: [
1744 fakeProvider("github", {
1745 getChangeRequestActivity: () => {
1746 activityCalls += 1;
1747 return Effect.succeed({
1748 comments: [],
1749 commentCount: 0,
1750 commentsTruncated: false,
1751 reviewThreads: [],
1752 commits: [],
1753 });
1754 },
1755 }),
1756 ],
1757 });
1758
1759 yield* service.activity(reference);
1760 assert.strictEqual(activityCalls, 1);
1761
1762 yield* service.setReaction({ ...reference, content: "heart", reacted: true });
1763 yield* service.activity(reference);
1764
1765 assert.strictEqual(activityCalls, 2);
1766 }),
1767);
1768
1483it.effect("refuses an empty reply before it reaches the host", () =>1769it.effect("refuses an empty reply before it reaches the host", () =>
1484 Effect.gen(function* () {1770 Effect.gen(function* () {
1485 const service = yield* makeService({1771 const service = yield* makeService({
...@@ -1521,6 +1807,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>...@@ -1521,6 +1807,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>
1521 // Azure DevOps's shape: it squashes as a completion option and has no rebase.1807 // Azure DevOps's shape: it squashes as a completion option and has no rebase.
1522 mergeMethods: ["merge", "squash"],1808 mergeMethods: ["merge", "squash"],
1523 search: true,1809 search: true,
1810 reactions: true,
1524 review: FULL_REVIEW,1811 review: FULL_REVIEW,
1525 reviewers: FULL_REVIEWERS,1812 reviewers: FULL_REVIEWERS,
1526 },1813 },
...@@ -1704,6 +1991,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is...@@ -1704,6 +1991,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is
1704 actions: ["merge"],1991 actions: ["merge"],
1705 mergeMethods: ["merge"],1992 mergeMethods: ["merge"],
1706 search: true,1993 search: true,
1994 reactions: true,
1707 review: FULL_REVIEW,1995 review: FULL_REVIEW,
1708 reviewers: { request: false, listCandidates: false },1996 reviewers: { request: false, listCandidates: false },
1709 },1997 },
...@@ -1744,6 +2032,7 @@ it.effect("refuses the candidate list on a host that has no such list to give",...@@ -1744,6 +2032,7 @@ it.effect("refuses the candidate list on a host that has no such list to give",
1744 actions: ["merge"],2032 actions: ["merge"],
1745 mergeMethods: ["merge"],2033 mergeMethods: ["merge"],
1746 search: false,2034 search: false,
2035 reactions: true,
1747 review: FULL_REVIEW,2036 review: FULL_REVIEW,
1748 // Azure's shape: it takes a reviewer, and names nobody who could be one.2037 // Azure's shape: it takes a reviewer, and names nobody who could be one.
1749 reviewers: { request: true, listCandidates: false },2038 reviewers: { request: true, listCandidates: false },
...@@ -1910,6 +2199,45 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o...@@ -1910,6 +2199,45 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o
1910 }),2199 }),
1911);2200);
19122201
2202it.effect("a listing narrowed to some projects is its own cache entry", () =>
2203 Effect.gen(function* () {
2204 const asked: ReadonlyArray<string>[] = [];
2205 const service = yield* makeService({
2206 projects: [
2207 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
2208 project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }),
2209 ],
2210 providers: [
2211 fakeProvider("github", {
2212 listChangeRequestsAcross: (input) => {
2213 asked.push(input.repositories);
2214 return Effect.succeed({
2215 items: input.repositories.map((repository, index) =>
2216 batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"),
2217 ),
2218 truncated: false,
2219 });
2220 },
2221 }),
2222 ],
2223 });
2224
2225 yield* service.list({ state: "open" });
2226 const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] });
2227
2228 // The narrowing is part of the key, so it reads its own scope instead of the wider answer.
2229 assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]);
2230 assert.deepStrictEqual(
2231 narrowed.entries.map((entry) => entry.repository),
2232 ["acme/docs"],
2233 );
2234
2235 // Asking again with the same narrowing, ordered differently, is still the same answer.
2236 yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] });
2237 assert.strictEqual(asked.length, 2);
2238 }),
2239);
2240
1913it.effect("an explicit invalidation makes the next listing ask the host again", () =>2241it.effect("an explicit invalidation makes the next listing ask the host again", () =>
1914 Effect.gen(function* () {2242 Effect.gen(function* () {
1915 let hostCalls = 0;2243 let hostCalls = 0;
...@@ -2370,3 +2698,597 @@ it.effect(...@@ -2370,3 +2698,597 @@ it.effect(
2370 assert.strictEqual(activityCalls, 2);2698 assert.strictEqual(activityCalls, 2);
2371 }),2699 }),
2372);2700);
2701
2702it.effect("carries an armed auto-merge through to the detail, and silence as silence", () =>
2703 Effect.gen(function* () {
2704 const detailWith = (autoMergeEnabled: boolean | undefined) =>
2705 Effect.gen(function* () {
2706 const service = yield* makeService({
2707 projects: [
2708 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
2709 ],
2710 providers: [
2711 fakeProvider("github", {
2712 getChangeRequest: () =>
2713 Effect.succeed({
2714 ...changeRequest(1, "2026-07-02T00:00:00Z"),
2715 body: "",
2716 changedFiles: 0,
2717 mergedAt: null,
2718 closedAt: null,
2719 reviewers: [],
2720 checks: [],
2721 mergeCapabilities: { merge: true, squash: true, rebase: true },
2722 viewerPermissions: {
2723 actions: ["merge"],
2724 comment: true,
2725 resolve: true,
2726 verdicts: ["comment", "approve", "request-changes"],
2727 requestReviewers: true,
2728 },
2729 ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }),
2730 }),
2731 }),
2732 ],
2733 });
2734 return yield* service.detail({
2735 projectId: "p1" as ProjectId,
2736 repository: "acme/web",
2737 number: 1,
2738 });
2739 });
2740
2741 assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true);
2742 assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false);
2743 // A host that says nothing leaves the field absent rather than claiming the merge is unarmed.
2744 assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled);
2745 }),
2746);
2747
2748it("names an Azure DevOps repository by its own name, not its project path", () => {
2749 // `az repos pr list --repository` takes a name and detects the organisation and project from
2750 // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
2751 // reads as unavailable on the page.
2752 const selector = PullRequestService.repositoryIdentityOf({
2753 repositoryIdentity: {
2754 provider: "azure-devops",
2755 displayName: "contoso/payments/_git/checkout",
2756 owner: "contoso",
2757 name: "checkout",
2758 },
2759 } as never);
2760 assert.strictEqual(selector, "checkout");
2761});
2762
2763it("falls back to the path's last segment where an Azure identity has no name", () => {
2764 const selector = PullRequestService.repositoryIdentityOf({
2765 repositoryIdentity: {
2766 provider: "azure-devops",
2767 displayName: "contoso/payments/_git/checkout",
2768 },
2769 } as never);
2770 assert.strictEqual(selector, "checkout");
2771});
2772
2773it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
2774 const selector = PullRequestService.repositoryIdentityOf({
2775 repositoryIdentity: {
2776 provider: "gitlab",
2777 displayName: "group/subgroup/service",
2778 owner: "group",
2779 name: "service",
2780 },
2781 } as never);
2782 assert.strictEqual(selector, "group/subgroup/service");
2783});
2784
2785it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
2786 Effect.gen(function* () {
2787 const service = yield* makeService({
2788 projects: [
2789 project({
2790 id: "p1",
2791 title: "web",
2792 workspaceRoot: "/a",
2793 repository: "acme/web",
2794 provider: "gitlab",
2795 }),
2796 ],
2797 providers: [
2798 // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and
2799 // sending it a draft filter it quietly ignores used to put drafts on a filtered page.
2800 fakeProvider("gitlab", {
2801 listChangeRequests: () =>
2802 Effect.succeed({
2803 items: [
2804 { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true },
2805 changeRequest(2, "2026-07-01T00:00:00Z"),
2806 ],
2807 truncated: false,
2808 continues: false,
2809 }),
2810 }),
2811 ],
2812 });
2813
2814 const result = yield* service.list({ state: "open", filters: { draft: "hide" } });
2815
2816 assert.deepStrictEqual(
2817 result.entries.map((entry) => entry.number),
2818 [2],
2819 );
2820 }),
2821);
2822
2823it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () =>
2824 Effect.gen(function* () {
2825 const sized = (number: number, updatedAt: string, ...names: ReadonlyArray<string>) => ({
2826 ...changeRequest(number, updatedAt),
2827 labels: names.map((name) => ({ name, color: null })),
2828 });
2829 const service = yield* makeService({
2830 projects: [
2831 project({
2832 id: "p1",
2833 title: "web",
2834 workspaceRoot: "/a",
2835 repository: "acme/web",
2836 provider: "gitlab",
2837 }),
2838 ],
2839 providers: [
2840 fakeProvider("gitlab", {
2841 listChangeRequests: () =>
2842 Effect.succeed({
2843 items: [
2844 sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"),
2845 sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"),
2846 sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"),
2847 sized(4, "2026-07-01T00:00:00Z", "size:S"),
2848 ],
2849 truncated: false,
2850 continues: false,
2851 }),
2852 }),
2853 ],
2854 });
2855
2856 // Either size satisfies the first group; the second group is its own question, so the row
2857 // carrying a size but no bug goes.
2858 const result = yield* service.list({
2859 state: "open",
2860 filters: { labels: [["size:S", "size:XS"], ["bug"]] },
2861 });
2862
2863 assert.deepStrictEqual(
2864 result.entries.map((entry) => entry.number),
2865 [1, 2],
2866 );
2867 }),
2868);
2869
2870it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () =>
2871 Effect.gen(function* () {
2872 const service = yield* makeService({
2873 projects: [
2874 project({
2875 id: "p1",
2876 title: "web",
2877 workspaceRoot: "/a",
2878 repository: "acme/web",
2879 provider: "gitlab",
2880 }),
2881 ],
2882 providers: [
2883 // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved
2884 // locally too — the same helper both call sites lean on.
2885 fakeProvider("gitlab", {
2886 listChangeRequests: () =>
2887 Effect.succeed({
2888 items: [
2889 changeRequest(1, "2026-07-02T00:00:00Z"),
2890 {
2891 ...changeRequest(2, "2026-07-01T00:00:00Z"),
2892 author: { login: "bilal", name: null, avatarUrl: null },
2893 },
2894 ],
2895 truncated: false,
2896 continues: false,
2897 }),
2898 }),
2899 ],
2900 });
2901
2902 const result = yield* service.list({ state: "open", filters: { author: "me" } });
2903
2904 assert.deepStrictEqual(
2905 result.entries.map((entry) => entry.number),
2906 [2],
2907 );
2908 }),
2909);
2910
2911it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
2912 Effect.gen(function* () {
2913 let taken: string | null = null;
2914 const service = yield* makeService({
2915 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2916 providers: [
2917 fakeProvider("github", {
2918 capabilities: {
2919 diff: true,
2920 comment: true,
2921 actions: ["merge", "close", "update-branch"],
2922 mergeMethods: ["merge"],
2923 // This host brings a stale branch up to date with a merge commit and nothing else.
2924 updateMethods: ["merge"],
2925 search: true,
2926 reactions: true,
2927 review: FULL_REVIEW,
2928 reviewers: FULL_REVIEWERS,
2929 },
2930 getViewerPermissions: () =>
2931 Effect.succeed({
2932 actions: ["close", "update-branch"],
2933 comment: true,
2934 resolve: true,
2935 verdicts: ["comment"],
2936 requestReviewers: false,
2937 updateMethods: ["merge"],
2938 }),
2939 runAction: (input) => {
2940 taken = input.updateMethod ?? "default";
2941 return Effect.void;
2942 },
2943 }),
2944 ],
2945 });
2946 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
2947
2948 // Asking for a rebase a host does not offer must fail rather than quietly merge instead.
2949 const error = yield* Effect.flip(
2950 service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }),
2951 );
2952 assert.strictEqual(error._tag, "PullRequestOperationError");
2953 assert.strictEqual(taken, null);
2954
2955 yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" });
2956 assert.strictEqual(taken, "merge");
2957 }),
2958);
2959
2960it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () =>
2961 Effect.gen(function* () {
2962 let taken = 0;
2963 const service = yield* makeService({
2964 projects: [
2965 project({
2966 id: "p1",
2967 title: "on gitlab",
2968 workspaceRoot: "/a",
2969 repository: "group/project",
2970 provider: "gitlab",
2971 }),
2972 ],
2973 providers: [
2974 fakeProvider("gitlab", {
2975 capabilities: {
2976 diff: true,
2977 comment: true,
2978 actions: ["merge", "close", "update-branch"],
2979 mergeMethods: ["merge"],
2980 // What GitLab declares: it replays the branch, and has no update that merges the
2981 // target back in.
2982 updateMethods: ["rebase"],
2983 search: true,
2984 reactions: true,
2985 review: FULL_REVIEW,
2986 reviewers: FULL_REVIEWERS,
2987 },
2988 getViewerPermissions: () =>
2989 Effect.succeed({
2990 actions: ["close", "update-branch"],
2991 comment: true,
2992 resolve: true,
2993 verdicts: ["comment"],
2994 requestReviewers: false,
2995 updateMethods: ["rebase"],
2996 }),
2997 runAction: () => {
2998 taken += 1;
2999 return Effect.void;
3000 },
3001 }),
3002 ],
3003 });
3004 const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 };
3005
3006 // A merge asked of a host that rebases must fail here rather than reach the provider, which
3007 // would rebase instead and report the wrong thing as done.
3008 const error = yield* Effect.flip(
3009 service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }),
3010 );
3011 assert.strictEqual(error._tag, "PullRequestOperationError");
3012 assert.strictEqual(taken, 0);
3013
3014 yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" });
3015 assert.strictEqual(taken, 1);
3016 }),
3017);
3018
3019it.effect("judges the review filter only on a host that summarises its reviews", () =>
3020 Effect.gen(function* () {
3021 const service = yield* makeService({
3022 projects: [
3023 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3024 project({
3025 id: "p2",
3026 title: "on gitlab",
3027 workspaceRoot: "/b",
3028 repository: "group/project",
3029 provider: "gitlab",
3030 }),
3031 ],
3032 providers: [
3033 // GitHub answers with the field on every row: null is "nobody has decided yet".
3034 fakeProvider("github", {
3035 listChangeRequests: () =>
3036 Effect.succeed({
3037 items: [
3038 { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewDecision: null },
3039 {
3040 ...changeRequest(2, "2026-07-02T00:00:00Z"),
3041 reviewDecision: "approved" as const,
3042 },
3043 ],
3044 truncated: false,
3045 continues: true,
3046 }),
3047 }),
3048 // GitLab never supplies the field, so its rows are not the filter's to judge.
3049 fakeProvider("gitlab", {
3050 listChangeRequests: () =>
3051 Effect.succeed({
3052 items: [changeRequest(3, "2026-07-02T00:00:00Z")],
3053 truncated: false,
3054 continues: true,
3055 }),
3056 }),
3057 ],
3058 });
3059
3060 const none = yield* service.list({ state: "open", filters: { review: "none" } });
3061 assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]);
3062
3063 const approved = yield* service.list({ state: "open", filters: { review: "approved" } });
3064 assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]);
3065 }),
3066);
3067
3068it.effect("sends only the words a rewrite carries", () =>
3069 Effect.gen(function* () {
3070 const received: Array<{ title?: string | undefined; body?: string | undefined }> = [];
3071 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3072 const service = yield* makeService({
3073 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3074 providers: [
3075 fakeProvider("github", {
3076 updateChangeRequest: (input) => {
3077 received.push({ title: input.title, body: input.body });
3078 return Effect.void;
3079 },
3080 }),
3081 ],
3082 });
3083
3084 yield* service.update({ ...reference, title: "A better title" });
3085 yield* service.update({ ...reference, body: "" });
3086 yield* service.update({ ...reference, title: "Both", body: "at once" });
3087
3088 assert.deepStrictEqual(received, [
3089 { title: "A better title", body: undefined },
3090 { title: undefined, body: "" },
3091 { title: "Both", body: "at once" },
3092 ]);
3093 }),
3094);
3095
3096it.effect("refuses a rewrite that changes nothing, before any call is made", () =>
3097 Effect.gen(function* () {
3098 const service = yield* makeService({
3099 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3100 providers: [
3101 fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }),
3102 ],
3103 });
3104
3105 const error = yield* Effect.flip(
3106 service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }),
3107 );
3108
3109 assert.strictEqual(error._tag, "PullRequestOperationError");
3110 assert.include(error.message, "Nothing was changed.");
3111 }),
3112);
3113
3114it.effect("refuses to rewrite anything on a host that never claimed it", () =>
3115 Effect.gen(function* () {
3116 const service = yield* makeService({
3117 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3118 providers: [
3119 fakeProvider("github", {
3120 capabilities: {
3121 diff: true,
3122 comment: true,
3123 actions: ["merge"],
3124 mergeMethods: ["merge"],
3125 search: true,
3126 reactions: true,
3127 review: FULL_REVIEW,
3128 reviewers: FULL_REVIEWERS,
3129 },
3130 updateChangeRequest: () => Effect.die("must not be called"),
3131 updateComment: () => Effect.die("must not be called"),
3132 }),
3133 ],
3134 });
3135 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3136
3137 const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" }));
3138 const commentRefused = yield* Effect.flip(
3139 service.updateComment({
3140 ...reference,
3141 commentId: "IC_1",
3142 kind: "issue-comment",
3143 body: "New",
3144 }),
3145 );
3146
3147 assert.include(rewriteRefused.message, "cannot rewrite a change request.");
3148 assert.include(commentRefused.message, "cannot rewrite a comment.");
3149 }),
3150);
3151
3152it.effect("passes a rewritten remark through with the id and kind it arrived under", () =>
3153 Effect.gen(function* () {
3154 let received: { id: string; kind: string; body: string } | null = null;
3155 const service = yield* makeService({
3156 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3157 providers: [
3158 fakeProvider("github", {
3159 updateComment: (input) => {
3160 received = { id: input.commentId, kind: input.kind, body: input.body };
3161 return Effect.void;
3162 },
3163 }),
3164 ],
3165 });
3166
3167 yield* service.updateComment({
3168 projectId: "p1" as ProjectId,
3169 repository: "acme/web",
3170 number: 1,
3171 commentId: "PRRC_1",
3172 kind: "review-comment",
3173 body: "Second thoughts",
3174 });
3175
3176 assert.deepStrictEqual(received, {
3177 id: "PRRC_1",
3178 kind: "review-comment",
3179 body: "Second thoughts",
3180 });
3181 }),
3182);
3183
3184it.effect("refuses a remark rewritten into nothing but whitespace", () =>
3185 Effect.gen(function* () {
3186 const service = yield* makeService({
3187 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3188 providers: [
3189 fakeProvider("github", { updateComment: () => Effect.die("must not be called") }),
3190 ],
3191 });
3192
3193 const error = yield* Effect.flip(
3194 service.updateComment({
3195 projectId: "p1" as ProjectId,
3196 repository: "acme/web",
3197 number: 1,
3198 commentId: "IC_1",
3199 kind: "issue-comment",
3200 body: " \n ",
3201 }),
3202 );
3203
3204 assert.include(error.message, "A comment cannot be empty.");
3205 }),
3206);
3207
3208it.effect("forgets the cached detail after a rewrite, like the other mutations", () =>
3209 Effect.gen(function* () {
3210 let coreCalls = 0;
3211 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3212 const service = yield* makeService({
3213 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3214 providers: [
3215 fakeProvider("github", {
3216 getChangeRequest: () => {
3217 coreCalls += 1;
3218 return Effect.succeed({
3219 ...changeRequest(1, "2026-07-02T00:00:00Z"),
3220 body: "",
3221 changedFiles: 0,
3222 mergedAt: null,
3223 closedAt: null,
3224 reviewers: [],
3225 checks: [],
3226 mergeCapabilities: { merge: true, squash: true, rebase: true },
3227 viewerPermissions: {
3228 actions: ["merge"],
3229 comment: true,
3230 resolve: true,
3231 verdicts: ["comment", "approve", "request-changes"],
3232 requestReviewers: true,
3233 },
3234 });
3235 },
3236 }),
3237 ],
3238 });
3239
3240 yield* service.detail(reference);
3241 yield* service.update({ ...reference, title: "Renamed" });
3242 yield* service.detail(reference);
3243
3244 assert.strictEqual(coreCalls, 2);
3245 }),
3246);
3247
3248it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () =>
3249 Effect.gen(function* () {
3250 const detailFrom = (provider: PullRequestProviderApi) =>
3251 Effect.gen(function* () {
3252 const service = yield* makeService({
3253 projects: [
3254 project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3255 ],
3256 providers: [provider],
3257 });
3258 return yield* service.detail({
3259 projectId: "p1" as ProjectId,
3260 repository: "acme/web",
3261 number: 1,
3262 });
3263 });
3264 const readable = fakeProvider("github", {
3265 getChangeRequest: () =>
3266 Effect.succeed({
3267 ...changeRequest(1, "2026-07-02T00:00:00Z"),
3268 body: "",
3269 changedFiles: 0,
3270 mergedAt: null,
3271 closedAt: null,
3272 reviewers: [],
3273 checks: [],
3274 mergeCapabilities: { merge: true, squash: true, rebase: true },
3275 viewerPermissions: {
3276 actions: ["merge"],
3277 comment: true,
3278 resolve: true,
3279 verdicts: ["comment", "approve", "request-changes"],
3280 requestReviewers: true,
3281 },
3282 }),
3283 });
3284
3285 const named = yield* detailFrom(readable);
3286 const unnamed = yield* detailFrom({
3287 ...readable,
3288 getViewer: () => Effect.fail(unusable("github", "unauthenticated")),
3289 });
3290
3291 assert.strictEqual(named.viewer, "bilal");
3292 assert.strictEqual(unnamed.viewer, undefined);
3293 }),
3294);
apps/server/src/pullRequest/PullRequestService.ts+323-50
...@@ -10,11 +10,13 @@ import {...@@ -10,11 +10,13 @@ import {
10 PullRequestUnavailableError,10 PullRequestUnavailableError,
11 pullRequestHostOf,11 pullRequestHostOf,
12 pullRequestProviderRequirement,12 pullRequestProviderRequirement,
13 resolvePullRequestAuthorFilter,
13 type OrchestrationProjectShell,14 type OrchestrationProjectShell,
14 type PullRequestAction,15 type PullRequestAction,
15 type PullRequestActionInput,16 type PullRequestActionInput,
16 type PullRequestActivity,17 type PullRequestActivity,
17 type PullRequestCommentInput,18 type PullRequestCommentInput,
19 type PullRequestCommentUpdateInput,
18 type PullRequestDetail,20 type PullRequestDetail,
19 type PullRequestDiffFileContentsInput,21 type PullRequestDiffFileContentsInput,
20 type PullRequestDiffFileContentsResult,22 type PullRequestDiffFileContentsResult,
...@@ -23,12 +25,14 @@ import {...@@ -23,12 +25,14 @@ import {
23 type PullRequestDiffResult,25 type PullRequestDiffResult,
24 type PullRequestInvalidateInput,26 type PullRequestInvalidateInput,
25 type PullRequestListEntry,27 type PullRequestListEntry,
28 type PullRequestListFilters,
26 type PullRequestListInput,29 type PullRequestListInput,
27 type PullRequestListProjectError,30 type PullRequestListProjectError,
28 type PullRequestListResult,31 type PullRequestListResult,
29 type PullRequestListStatsInput,32 type PullRequestListStatsInput,
30 type PullRequestListStatsResult,33 type PullRequestListStatsResult,
31 type PullRequestProviderSummary,34 type PullRequestProviderSummary,
35 type PullRequestReactionInput,
32 type PullRequestRef,36 type PullRequestRef,
33 type PullRequestReviewVerdict,37 type PullRequestReviewVerdict,
34 type PullRequestReviewerCandidateList,38 type PullRequestReviewerCandidateList,
...@@ -36,6 +40,7 @@ import {...@@ -36,6 +40,7 @@ import {
36 type PullRequestSubmitReviewInput,40 type PullRequestSubmitReviewInput,
37 type PullRequestThreadReplyInput,41 type PullRequestThreadReplyInput,
38 type PullRequestThreadResolutionInput,42 type PullRequestThreadResolutionInput,
43 type PullRequestUpdateInput,
39 type SourceControlProviderInfo,44 type SourceControlProviderInfo,
40 type SourceControlProviderKind,45 type SourceControlProviderKind,
41} from "@t3tools/contracts";46} from "@t3tools/contracts";
...@@ -134,7 +139,11 @@ export class PullRequestService extends Context.Service<...@@ -134,7 +139,11 @@ export class PullRequestService extends Context.Service<
134 input: PullRequestDiffFileContentsInput,139 input: PullRequestDiffFileContentsInput,
135 ) => Effect.Effect<PullRequestDiffFileContentsResult, PullRequestError>;140 ) => Effect.Effect<PullRequestDiffFileContentsResult, PullRequestError>;
136 readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>;141 readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>;
142 readonly update: (input: PullRequestUpdateInput) => Effect.Effect<void, PullRequestError>;
137 readonly comment: (input: PullRequestCommentInput) => Effect.Effect<void, PullRequestError>;143 readonly comment: (input: PullRequestCommentInput) => Effect.Effect<void, PullRequestError>;
144 readonly updateComment: (
145 input: PullRequestCommentUpdateInput,
146 ) => Effect.Effect<void, PullRequestError>;
138 readonly submitReview: (147 readonly submitReview: (
139 input: PullRequestSubmitReviewInput,148 input: PullRequestSubmitReviewInput,
140 ) => Effect.Effect<void, PullRequestError>;149 ) => Effect.Effect<void, PullRequestError>;
...@@ -144,6 +153,9 @@ export class PullRequestService extends Context.Service<...@@ -144,6 +153,9 @@ export class PullRequestService extends Context.Service<
144 readonly setThreadResolution: (153 readonly setThreadResolution: (
145 input: PullRequestThreadResolutionInput,154 input: PullRequestThreadResolutionInput,
146 ) => Effect.Effect<void, PullRequestError>;155 ) => Effect.Effect<void, PullRequestError>;
156 readonly setReaction: (
157 input: PullRequestReactionInput,
158 ) => Effect.Effect<void, PullRequestError>;
147 readonly reviewerCandidates: (159 readonly reviewerCandidates: (
148 input: PullRequestRef,160 input: PullRequestRef,
149 ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestError>;161 ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestError>;
...@@ -174,8 +186,14 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {...@@ -174,8 +186,14 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {
174 "You need write access on this repository, or to have opened this change request, to return it to a draft.",186 "You need write access on this repository, or to have opened this change request, to return it to a draft.",
175 close:187 close:
176 "You need write access on this repository, or to have opened this change request, to close it.",188 "You need write access on this repository, or to have opened this change request, to close it.",
189 "update-branch":
190 "You need write access on this repository, or to have opened this change request, to update its branch.",
177 reopen:191 reopen:
178 "You need write access on this repository, or to have opened this change request, to reopen it.",192 "You need write access on this repository, or to have opened this change request, to reopen it.",
193 "enable-auto-merge":
194 "You need write access on this repository to have it merged for you once it is ready.",
195 "disable-auto-merge":
196 "You need write access on this repository to stop it being merged for you once it is ready.",
179};197};
180198
181/**199/**
...@@ -349,13 +367,25 @@ function toPullRequestError(...@@ -349,13 +367,25 @@ function toPullRequestError(
349}367}
350368
351/**369/**
352 * The provider-native repository identity. `displayName` is the full path below the host, which370 * The provider-native repository selector. `displayName` is the full path below the host, which
353 * is what nested GitLab groups and Azure project paths need; owner/name is the two-segment371 * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
354 * fallback for identities recorded before that field existed.372 * recorded before that field existed.
373 *
374 * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
375 * takes the organisation and project from the checkout it detects — so the recorded
376 * `org/project/_git/repo` path is refused outright and the whole repository reads as
377 * unavailable. Its name is the last segment, which is what this hands over.
378 *
379 * One function because everything downstream is keyed by what it answers: the rows' own
380 * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
355 */381 */
356function repositoryIdentityOf(project: OrchestrationProjectShell): string | null {382export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null {
357 const identity = project.repositoryIdentity;383 const identity = project.repositoryIdentity;
358 if (!identity) return null;384 if (!identity) return null;
385 if (identity.provider === "azure-devops") {
386 const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
387 return identity.name || segments.at(-1) || null;
388 }
359 if (identity.displayName) return identity.displayName;389 if (identity.displayName) return identity.displayName;
360 return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;390 return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
361}391}
...@@ -424,7 +454,7 @@ export const make = Effect.gen(function* () {...@@ -424,7 +454,7 @@ export const make = Effect.gen(function* () {
424 };454 };
425455
426 const listWorkspaceProjects = (456 const listWorkspaceProjects = (
427 filter: Pick<PullRequestListInput, "projectId" | "host">,457 filter: Pick<PullRequestListInput, "projectId" | "projectIds" | "host">,
428 ): Effect.Effect<WorkspaceProjects, PullRequestError> =>458 ): Effect.Effect<WorkspaceProjects, PullRequestError> =>
429 projections.getShellSnapshot().pipe(459 projections.getShellSnapshot().pipe(
430 Effect.mapError(460 Effect.mapError(
...@@ -450,6 +480,7 @@ export const make = Effect.gen(function* () {...@@ -450,6 +480,7 @@ export const make = Effect.gen(function* () {
450 const seen = new Set<string>();480 const seen = new Set<string>();
451 for (const project of snapshot.projects) {481 for (const project of snapshot.projects) {
452 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;482 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
483 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
453 const identity = project.repositoryIdentity;484 const identity = project.repositoryIdentity;
454 let kind = identity?.provider as SourceControlProviderKind | undefined;485 let kind = identity?.provider as SourceControlProviderKind | undefined;
455 const repository = repositoryIdentityOf(project);486 const repository = repositoryIdentityOf(project);
...@@ -603,6 +634,43 @@ export const make = Effect.gen(function* () {...@@ -603,6 +634,43 @@ export const make = Effect.gen(function* () {
603 { concurrency: REPOSITORY_CONCURRENCY },634 { concurrency: REPOSITORY_CONCURRENCY },
604 );635 );
605636
637 /**
638 * The narrowings a row can be judged by from its own fields, applied here rather than trusted
639 * to the host. Only GitHub is asked to narrow a listing for itself; every other provider
640 * answers unnarrowed, and without this pass a draft filter or a label filter would be sent,
641 * accepted and quietly ignored. Idempotent for the hosts that did narrow.
642 *
643 * `checks` is absent because no listed row carries its check state: that one filter is the
644 * host's alone, and a row nobody narrowed stays rather than being guessed at.
645 */
646 const matchesRowFilters = (
647 item: ProviderChangeRequest,
648 filters: PullRequestListFilters | undefined,
649 viewer: string,
650 ): boolean => {
651 if (filters === undefined) return true;
652 const labels = item.labels.map((label) => label.name.trim().toLowerCase());
653 const holds = (label: string) => labels.includes(label.trim().toLowerCase());
654 return (
655 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
656 // Judged on the provider row rather than the entry, because the two absences mean
657 // different things and the entry keeps only one of them: `null` is a host that summarises
658 // its reviews saying there is no decision yet, which is what "none" asks for, while
659 // `undefined` is a host that does not summarise at all — an unjudgeable row, left alone
660 // the way an unreadable check state is.
661 (filters.review === undefined ||
662 item.reviewDecision === undefined ||
663 (filters.review === "none"
664 ? item.reviewDecision === null
665 : item.reviewDecision === filters.review)) &&
666 (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&
667 (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&
668 (filters.author === undefined ||
669 item.author?.login.toLowerCase() ===
670 resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase())
671 );
672 };
673
606 const toEntry = (input: {674 const toEntry = (input: {
607 readonly project: SupportedProject;675 readonly project: SupportedProject;
608 readonly item: ProviderChangeRequest;676 readonly item: ProviderChangeRequest;
...@@ -628,10 +696,16 @@ export const make = Effect.gen(function* () {...@@ -628,10 +696,16 @@ export const make = Effect.gen(function* () {
628 deletions: input.item.deletions,696 deletions: input.item.deletions,
629 createdAt: input.item.createdAt,697 createdAt: input.item.createdAt,
630 updatedAt: input.item.updatedAt,698 updatedAt: input.item.updatedAt,
699 ...(input.item.checksState === undefined || input.item.checksState === null
700 ? {}
701 : { checksState: input.item.checksState }),
631 viewerReviewRequested:702 viewerReviewRequested:
632 input.item.author?.login.toLowerCase() !== viewer &&703 input.item.author?.login.toLowerCase() !== viewer &&
633 input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer),704 input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer),
634 labels: input.item.labels,705 labels: input.item.labels,
706 ...(input.item.reviewDecision === undefined || input.item.reviewDecision === null
707 ? {}
708 : { reviewDecision: input.item.reviewDecision }),
635 };709 };
636 };710 };
637711
...@@ -755,6 +829,7 @@ export const make = Effect.gen(function* () {...@@ -755,6 +829,7 @@ export const make = Effect.gen(function* () {
755 // Each host matches this its own way, and one that cannot match text at all829 // Each host matches this its own way, and one that cannot match text at all
756 // answers unnarrowed rather than failing.830 // answers unnarrowed rather than failing.
757 query: input.query,831 query: input.query,
832 filters: input.filters,
758 // Only the two fields a host can act on: which rows have already been sent at the833 // Only the two fields a host can act on: which rows have already been sent at the
759 // boundary instant is this service's business, not a provider's.834 // boundary instant is this service's business, not a provider's.
760 ...(cursor === undefined835 ...(cursor === undefined
...@@ -778,7 +853,9 @@ export const make = Effect.gen(function* () {...@@ -778,7 +853,9 @@ export const make = Effect.gen(function* () {
778 );853 );
779 return {854 return {
780 key,855 key,
781 entries: items.map((item) => toEntry({ project, item, viewer })),856 entries: items
857 .filter((item) => matchesRowFilters(item, input.filters, viewer))
858 .map((item) => toEntry({ project, item, viewer })),
782 errors: [],859 errors: [],
783 truncated: page.truncated,860 truncated: page.truncated,
784 nextCursor:861 nextCursor:
...@@ -836,6 +913,7 @@ export const make = Effect.gen(function* () {...@@ -836,6 +913,7 @@ export const make = Effect.gen(function* () {
836 viewer,913 viewer,
837 limit,914 limit,
838 query: input.query,915 query: input.query,
916 filters: input.filters,
839 ...(cursor === undefined917 ...(cursor === undefined
840 ? {}918 ? {}
841 : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),919 : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }),
...@@ -881,7 +959,9 @@ export const make = Effect.gen(function* () {...@@ -881,7 +959,9 @@ export const make = Effect.gen(function* () {
881 );959 );
882 return Effect.succeed({960 return Effect.succeed({
883 key: listCursorKey(project.host, project.repository),961 key: listCursorKey(project.host, project.repository),
884 entries: items.map((item) => toEntry({ project, item, viewer })),962 entries: items
963 .filter((item) => matchesRowFilters(item, input.filters, viewer))
964 .map((item) => toEntry({ project, item, viewer })),
885 errors: [],965 errors: [],
886 truncated: page.truncated,966 truncated: page.truncated,
887 nextCursor:967 nextCursor:
...@@ -939,51 +1019,72 @@ export const make = Effect.gen(function* () {...@@ -939,51 +1019,72 @@ export const make = Effect.gen(function* () {
939 };1019 };
940 });1020 });
9411021
1022 /**
1023 * Who this project's host says the reader is. Shared with the listing's own lookup — the same
1024 * ten-minute answer per host — so a page that has already listed anything pays nothing for it,
1025 * and a host that cannot say leaves it null rather than failing the read it decorates.
1026 */
1027 const viewerOf = (project: SupportedProject): Effect.Effect<string | null> =>
1028 resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null));
1029
942 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>1030 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
943 requireProject(input).pipe(1031 requireProject(input).pipe(
944 Effect.flatMap((project) =>1032 Effect.flatMap((project) =>
945 project.api1033 Effect.all(
946 .getChangeRequest({1034 [
947 cwd: project.project.workspaceRoot,1035 project.api
948 repository: project.repository,1036 .getChangeRequest({
949 host: project.host,1037 cwd: project.project.workspaceRoot,
950 number: input.number,
951 })
952 .pipe(
953 Effect.mapError(toPullRequestError("detail")),
954 Effect.map(
955 (changeRequest): PullRequestDetail => ({
956 provider: project.api.kind,
957 capabilities: project.api.capabilities,
958 projectId: project.project.id,
959 projectTitle: project.project.title,
960 workspaceRoot: project.project.workspaceRoot,
961 repository: project.repository,1038 repository: project.repository,
962 number: changeRequest.number,1039 host: project.host,
963 title: changeRequest.title,1040 number: input.number,
964 body: changeRequest.body,1041 })
965 url: changeRequest.url,1042 .pipe(Effect.mapError(toPullRequestError("detail"))),
966 author: changeRequest.author,1043 viewerOf(project),
967 state: changeRequest.state,1044 ],
968 isDraft: changeRequest.isDraft,1045 { concurrency: 2 },
969 mergeability: changeRequest.mergeability,1046 ).pipe(
970 additions: changeRequest.additions,1047 Effect.map(
971 deletions: changeRequest.deletions,1048 ([changeRequest, viewer]): PullRequestDetail => ({
972 changedFiles: changeRequest.changedFiles,1049 provider: project.api.kind,
973 headBranch: changeRequest.headBranch,1050 capabilities: project.api.capabilities,
974 baseBranch: changeRequest.baseBranch,1051 projectId: project.project.id,
975 createdAt: changeRequest.createdAt,1052 projectTitle: project.project.title,
976 updatedAt: changeRequest.updatedAt,1053 workspaceRoot: project.project.workspaceRoot,
977 mergedAt: changeRequest.mergedAt,1054 repository: project.repository,
978 closedAt: changeRequest.closedAt,1055 number: changeRequest.number,
979 reviewers: changeRequest.reviewers,1056 title: changeRequest.title,
980 labels: changeRequest.labels,1057 body: changeRequest.body,
981 checks: changeRequest.checks,1058 url: changeRequest.url,
982 mergeCapabilities: changeRequest.mergeCapabilities,1059 author: changeRequest.author,
983 viewerPermissions: changeRequest.viewerPermissions,1060 state: changeRequest.state,
984 }),1061 isDraft: changeRequest.isDraft,
985 ),1062 mergeability: changeRequest.mergeability,
1063 additions: changeRequest.additions,
1064 deletions: changeRequest.deletions,
1065 changedFiles: changeRequest.changedFiles,
1066 headBranch: changeRequest.headBranch,
1067 baseBranch: changeRequest.baseBranch,
1068 createdAt: changeRequest.createdAt,
1069 updatedAt: changeRequest.updatedAt,
1070 mergedAt: changeRequest.mergedAt,
1071 closedAt: changeRequest.closedAt,
1072 reviewers: changeRequest.reviewers,
1073 labels: changeRequest.labels,
1074 checks: changeRequest.checks,
1075 mergeCapabilities: changeRequest.mergeCapabilities,
1076 viewerPermissions: changeRequest.viewerPermissions,
1077 ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }),
1078 ...(changeRequest.baseComparison === undefined
1079 ? {}
1080 : { baseComparison: changeRequest.baseComparison }),
1081 ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }),
1082 ...(changeRequest.autoMergeEnabled === undefined
1083 ? {}
1084 : { autoMergeEnabled: changeRequest.autoMergeEnabled }),
1085 }),
986 ),1086 ),
1087 ),
987 ),1088 ),
988 );1089 );
9891090
...@@ -1008,6 +1109,7 @@ export const make = Effect.gen(function* () {...@@ -1008,6 +1109,7 @@ export const make = Effect.gen(function* () {
1008 commentsTruncated: activity.commentsTruncated,1109 commentsTruncated: activity.commentsTruncated,
1009 reviewThreads: activity.reviewThreads,1110 reviewThreads: activity.reviewThreads,
1010 commits: activity.commits,1111 commits: activity.commits,
1112 ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }),
1011 }),1113 }),
1012 ),1114 ),
1013 ),1115 ),
...@@ -1088,6 +1190,19 @@ export const make = Effect.gen(function* () {...@@ -1088,6 +1190,19 @@ export const make = Effect.gen(function* () {
1088 }),1190 }),
1089 );1191 );
1090 }1192 }
1193 // The same for the way a stale branch is brought up to date: a host that only merges
1194 // must not be asked to rebase and left to pick something else.
1195 if (
1196 input.updateMethod !== undefined &&
1197 !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod)
1198 ) {
1199 return Effect.fail(
1200 new PullRequestOperationError({
1201 operation: "runAction",
1202 detail: `This host cannot update a branch by ${input.updateMethod}.`,
1203 }),
1204 );
1205 }
1091 // What the host can do and what this account may ask of it are two questions, and both1206 // What the host can do and what this account may ask of it are two questions, and both
1092 // have to say yes. The second is asked last, because it costs a request and the checks1207 // have to say yes. The second is asked last, because it costs a request and the checks
1093 // above do not.1208 // above do not.
...@@ -1101,6 +1216,17 @@ export const make = Effect.gen(function* () {...@@ -1101,6 +1216,17 @@ export const make = Effect.gen(function* () {
1101 }),1216 }),
1102 );1217 );
1103 }1218 }
1219 if (
1220 input.updateMethod !== undefined &&
1221 !(viewer.updateMethods ?? []).includes(input.updateMethod)
1222 ) {
1223 return Effect.fail(
1224 new PullRequestOperationError({
1225 operation: "runAction",
1226 detail: ACTION_ACCESS_REFUSALS["update-branch"],
1227 }),
1228 );
1229 }
1104 return project.api1230 return project.api
1105 .runAction({1231 .runAction({
1106 cwd: project.project.workspaceRoot,1232 cwd: project.project.workspaceRoot,
...@@ -1109,6 +1235,7 @@ export const make = Effect.gen(function* () {...@@ -1109,6 +1235,7 @@ export const make = Effect.gen(function* () {
1109 number: input.number,1235 number: input.number,
1110 action: input.action,1236 action: input.action,
1111 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),1237 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1238 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
1112 })1239 })
1113 .pipe(Effect.mapError(toPullRequestError("runAction")));1240 .pipe(Effect.mapError(toPullRequestError("runAction")));
1114 }),1241 }),
...@@ -1162,6 +1289,76 @@ export const make = Effect.gen(function* () {...@@ -1162,6 +1289,76 @@ export const make = Effect.gen(function* () {
1162 }),1289 }),
1163 );1290 );
11641291
1292 /**
1293 * Rewriting the change request's own words, and rewriting a remark, are both left to the host to
1294 * allow or refuse. Neither is a question a permission read answers: every host lets the person
1295 * who wrote something rewrite it whatever access they have otherwise, and none of them reports
1296 * that as a permission — so a check here could only guess, and a wrong guess takes the control
1297 * away from the one person certain to be allowed.
1298 */
1299 const update: PullRequestService["Service"]["update"] = (input) =>
1300 requireProject(input).pipe(
1301 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
1302 const rewrite = project.api.updateChangeRequest;
1303 if (project.api.capabilities.edit?.changeRequest !== true || rewrite === undefined) {
1304 return Effect.fail(
1305 new PullRequestOperationError({
1306 operation: "update",
1307 detail: "This host cannot rewrite a change request.",
1308 }),
1309 );
1310 }
1311 if (input.title === undefined && input.body === undefined) {
1312 return Effect.fail(
1313 new PullRequestOperationError({
1314 operation: "update",
1315 detail: "Nothing was changed.",
1316 }),
1317 );
1318 }
1319 return rewrite({
1320 cwd: project.project.workspaceRoot,
1321 repository: project.repository,
1322 host: project.host,
1323 number: input.number,
1324 ...(input.title === undefined ? {} : { title: input.title }),
1325 ...(input.body === undefined ? {} : { body: input.body }),
1326 }).pipe(Effect.mapError(toPullRequestError("update")));
1327 }),
1328 );
1329
1330 const updateComment: PullRequestService["Service"]["updateComment"] = (input) =>
1331 (input.body.trim().length === 0
1332 ? Effect.fail(
1333 new PullRequestOperationError({
1334 operation: "updateComment",
1335 detail: "A comment cannot be empty.",
1336 }),
1337 )
1338 : requireProject(input)
1339 ).pipe(
1340 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
1341 const rewrite = project.api.updateComment;
1342 if (project.api.capabilities.edit?.comment !== true || rewrite === undefined) {
1343 return Effect.fail(
1344 new PullRequestOperationError({
1345 operation: "updateComment",
1346 detail: "This host cannot rewrite a comment.",
1347 }),
1348 );
1349 }
1350 return rewrite({
1351 cwd: project.project.workspaceRoot,
1352 repository: project.repository,
1353 host: project.host,
1354 number: input.number,
1355 commentId: input.commentId,
1356 kind: input.kind,
1357 body: input.body,
1358 }).pipe(Effect.mapError(toPullRequestError("updateComment")));
1359 }),
1360 );
1361
1165 const submitReview: PullRequestService["Service"]["submitReview"] = (input) =>1362 const submitReview: PullRequestService["Service"]["submitReview"] = (input) =>
1166 requireProject(input).pipe(1363 requireProject(input).pipe(
1167 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {1364 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
...@@ -1297,6 +1494,36 @@ export const make = Effect.gen(function* () {...@@ -1297,6 +1494,36 @@ export const make = Effect.gen(function* () {
1297 }),1494 }),
1298 );1495 );
12991496
1497 /**
1498 * Reacting is gated on the host alone. Every host with reactions takes one from whoever can read
1499 * the change request, so there is no access left to check that reading it has not already
1500 * settled.
1501 */
1502 const setReaction: PullRequestService["Service"]["setReaction"] = (input) =>
1503 requireProject(input).pipe(
1504 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
1505 if (project.api.capabilities.reactions !== true) {
1506 return Effect.fail(
1507 new PullRequestOperationError({
1508 operation: "setReaction",
1509 detail: "This host has no reactions.",
1510 }),
1511 );
1512 }
1513 return project.api
1514 .setReaction({
1515 cwd: project.project.workspaceRoot,
1516 repository: project.repository,
1517 host: project.host,
1518 number: input.number,
1519 ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }),
1520 content: input.content,
1521 reacted: input.reacted,
1522 })
1523 .pipe(Effect.mapError(toPullRequestError("setReaction")));
1524 }),
1525 );
1526
1300 /**1527 /**
1301 * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is1528 * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is
1302 * the one the request is made from. So the same permission guards both: a page that could open1529 * the one the request is made from. So the same permission guards both: a page that could open
...@@ -1520,6 +1747,23 @@ export const make = Effect.gen(function* () {...@@ -1520,6 +1747,23 @@ export const make = Effect.gen(function* () {
1520 refEpochs.set(scope, ++epochCounter);1747 refEpochs.set(scope, ++epochCounter);
1521 };1748 };
15221749
1750 /** The positional filter slot of a cache key, back as the record `listUncached` takes. */
1751 const filtersOfKey = (
1752 slots: ReadonlyArray<
1753 string | ReadonlyArray<string> | ReadonlyArray<ReadonlyArray<string>> | null
1754 >,
1755 ): PullRequestListFilters => {
1756 const [draft, review, checks, author, labels, excludedLabels] = slots;
1757 return {
1758 ...(typeof draft === "string" ? { draft: draft as "only" | "hide" } : {}),
1759 ...(typeof review === "string" ? { review: review as PullRequestListFilters["review"] } : {}),
1760 ...(typeof checks === "string" ? { checks: checks as PullRequestListFilters["checks"] } : {}),
1761 ...(typeof author === "string" ? { author } : {}),
1762 ...(Array.isArray(labels) ? { labels: labels as ReadonlyArray<ReadonlyArray<string>> } : {}),
1763 ...(Array.isArray(excludedLabels) ? { excludedLabels } : {}),
1764 };
1765 };
1766
1523 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder1767 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
1524 // of in-flight state: concurrent identical reads coalesce on the key into one host request.1768 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
1525 // The continuation cursors are part of the key, entries sorted so one continuation is one1769 // The continuation cursors are part of the key, entries sorted so one continuation is one
...@@ -1528,13 +1772,24 @@ export const make = Effect.gen(function* () {...@@ -1528,13 +1772,24 @@ export const make = Effect.gen(function* () {
1528 (key: string) => {1772 (key: string) => {
1529 // The parse undoes this module's own serialization, so the shapes are known exactly;1773 // The parse undoes this module's own serialization, so the shapes are known exactly;
1530 // the cast restores the branded field types JSON cannot carry.1774 // the cast restores the branded field types JSON cannot carry.
1531 const [, state, involvement, projectId, host, limit, query, cursorEntries] = JSON.parse(1775 const [
1532 key,1776 ,
1533 ) as [1777 state,
1778 involvement,
1779 filters,
1780 projectId,
1781 projectIds,
1782 host,
1783 limit,
1784 query,
1785 cursorEntries,
1786 ] = JSON.parse(key) as [
1534 number,1787 number,
1535 string,1788 string,
1536 string | null,1789 string | null,
1790 ReadonlyArray<string | ReadonlyArray<string> | null> | null,
1537 string | null,1791 string | null,
1792 ReadonlyArray<string> | null,
1538 string | null,1793 string | null,
1539 number | null,1794 number | null,
1540 string | null,1795 string | null,
...@@ -1543,7 +1798,9 @@ export const make = Effect.gen(function* () {...@@ -1543,7 +1798,9 @@ export const make = Effect.gen(function* () {
1543 return listUncached({1798 return listUncached({
1544 state,1799 state,
1545 ...(involvement === null ? {} : { involvement }),1800 ...(involvement === null ? {} : { involvement }),
1801 ...(filters === null ? {} : { filters: filtersOfKey(filters) }),
1546 ...(projectId === null ? {} : { projectId }),1802 ...(projectId === null ? {} : { projectId }),
1803 ...(projectIds === null ? {} : { projectIds }),
1547 ...(host === null ? {} : { host }),1804 ...(host === null ? {} : { host }),
1548 ...(limit === null ? {} : { limit }),1805 ...(limit === null ? {} : { limit }),
1549 ...(query === null ? {} : { query }),1806 ...(query === null ? {} : { query }),
...@@ -1564,7 +1821,20 @@ export const make = Effect.gen(function* () {...@@ -1564,7 +1821,20 @@ export const make = Effect.gen(function* () {
1564 listingsEpoch,1821 listingsEpoch,
1565 input.state,1822 input.state,
1566 input.involvement ?? null,1823 input.involvement ?? null,
1824 // Positional so two identical filter sets key alike however their record was assembled.
1825 input.filters === undefined
1826 ? null
1827 : [
1828 input.filters.draft ?? null,
1829 input.filters.review ?? null,
1830 input.filters.checks ?? null,
1831 input.filters.author ?? null,
1832 input.filters.labels ?? null,
1833 input.filters.excludedLabels ?? null,
1834 ],
1567 input.projectId ?? null,1835 input.projectId ?? null,
1836 // Sorted so the same narrowing keys alike however the caller ordered it.
1837 input.projectIds === undefined ? null : [...input.projectIds].sort(),
1568 input.host ?? null,1838 input.host ?? null,
1569 input.limit ?? null,1839 input.limit ?? null,
1570 input.query ?? null,1840 input.query ?? null,
...@@ -1726,10 +1996,13 @@ export const make = Effect.gen(function* () {...@@ -1726,10 +1996,13 @@ export const make = Effect.gen(function* () {
1726 diff,1996 diff,
1727 diffFileContents,1997 diffFileContents,
1728 runAction: invalidatedByMutation(runAction),1998 runAction: invalidatedByMutation(runAction),
1999 update: invalidatedByMutation(update),
1729 comment: invalidatedByMutation(comment),2000 comment: invalidatedByMutation(comment),
2001 updateComment: invalidatedByMutation(updateComment),
1730 submitReview: invalidatedByMutation(submitReview),2002 submitReview: invalidatedByMutation(submitReview),
1731 replyToThread: invalidatedByMutation(replyToThread),2003 replyToThread: invalidatedByMutation(replyToThread),
1732 setThreadResolution: invalidatedByMutation(setThreadResolution),2004 setThreadResolution: invalidatedByMutation(setThreadResolution),
2005 setReaction: invalidatedByMutation(setReaction),
1733 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.2006 // The candidate list is deliberately read fresh per menu-open, so it stays uncached.
1734 reviewerCandidates,2007 reviewerCandidates,
1735 requestReviewers: invalidatedByMutation(requestReviewers),2008 requestReviewers: invalidatedByMutation(requestReviewers),
apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts+15
...@@ -144,6 +144,21 @@ describe("decodePullRequestJson", () => {...@@ -144,6 +144,21 @@ describe("decodePullRequestJson", () => {
144 ]);144 ]);
145 });145 });
146146
147 it("reads auto-complete from whoever armed it, and its absence as nobody", () => {
148 const armed = expectSuccess(
149 decodePullRequestJson(
150 asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })),
151 ),
152 );
153 expect(armed?.autoMergeEnabled).toBe(true);
154
155 // Azure leaves the field out entirely rather than sending it empty, so its absence is the
156 // whole of what it says about auto-complete being off.
157 expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe(
158 false,
159 );
160 });
161
147 it("works out where the conversation lives from what Azure returned", () => {162 it("works out where the conversation lives from what Azure returned", () => {
148 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));163 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
149164
apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts+9
...@@ -35,6 +35,12 @@ const RawPullRequestSchema = Schema.Struct({...@@ -35,6 +35,12 @@ const RawPullRequestSchema = Schema.Struct({
35 description: Schema.optional(Schema.NullOr(Schema.String)),35 description: Schema.optional(Schema.NullOr(Schema.String)),
36 status: Schema.optional(Schema.NullOr(Schema.String)),36 status: Schema.optional(Schema.NullOr(Schema.String)),
37 isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)),37 isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)),
38 /**
39 * Who armed auto-complete, which is the only thing Azure says about it: the field carries an
40 * identity while the pull request is set to complete on its own, and Azure leaves it out
41 * entirely once nobody has. So its presence is the answer, and there is no third state.
42 */
43 autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
38 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),44 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),
39 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),45 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
40 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),46 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),
...@@ -124,6 +130,8 @@ export interface AzureDevOpsPullRequest {...@@ -124,6 +130,8 @@ export interface AzureDevOpsPullRequest {
124 readonly reviewers: ReadonlyArray<PullRequestActor>;130 readonly reviewers: ReadonlyArray<PullRequestActor>;
125 /** Where this pull request's threads live, when Azure said enough to work it out. */131 /** Where this pull request's threads live, when Azure said enough to work it out. */
126 readonly threadsUrl: string | null;132 readonly threadsUrl: string | null;
133 /** Whether Azure is set to complete this on its own once its policies pass. */
134 readonly autoMergeEnabled: boolean;
127}135}
128136
129function trimmed(value: string | null | undefined): string | null {137function trimmed(value: string | null | undefined): string | null {
...@@ -223,6 +231,7 @@ function toPullRequest(...@@ -223,6 +231,7 @@ function toPullRequest(
223 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),231 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
224 reviewers,232 reviewers,
225 threadsUrl: toThreadsUrl(raw),233 threadsUrl: toThreadsUrl(raw),
234 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
226 };235 };
227}236}
228237
apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts+16
...@@ -315,6 +315,22 @@ describe("decodeStatusesJson", () => {...@@ -315,6 +315,22 @@ describe("decodeStatusesJson", () => {
315315
316 expect(decoded.items[0]?.status).toBe(expected);316 expect(decoded.items[0]?.status).toBe(expected);
317 });317 });
318
319 it("keeps two statuses that share a display name but have different keys", () => {
320 const decoded = expectSuccess(
321 decodeStatusesJson(
322 page([
323 { key: "build", name: "Pipeline", state: "SUCCESSFUL" },
324 { key: "deploy", name: "Pipeline", state: "FAILED" },
325 ]),
326 ),
327 );
328
329 expect(decoded.items.map((check) => [check.name, check.status])).toEqual([
330 ["build / Pipeline", "success"],
331 ["deploy / Pipeline", "failure"],
332 ]);
333 });
318});334});
319335
320describe("decodeDiffstatJson", () => {336describe("decodeDiffstatJson", () => {
apps/server/src/pullRequest/bitbucketPullRequestJson.ts+20-6
...@@ -18,6 +18,8 @@ import type {...@@ -18,6 +18,8 @@ import type {
18import { TrimmedNonEmptyString } from "@t3tools/contracts";18import { TrimmedNonEmptyString } from "@t3tools/contracts";
19import { decodeJsonResult } from "@t3tools/shared/schemaJson";19import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2020
21import { dedupeChecks } from "./pullRequestChecks.ts";
22
21/**23/**
22 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant
23 * style as the GitHub and GitLab decoders: a new pull request state or build status must not25 * style as the GitHub and GitLab decoders: a new pull request state or build status must not
...@@ -547,21 +549,33 @@ export function decodeStatusesJson(...@@ -547,21 +549,33 @@ export function decodeStatusesJson(
547 if (!Result.isSuccess(decoded)) {549 if (!Result.isSuccess(decoded)) {
548 return Result.fail(decoded.failure);550 return Result.fail(decoded.failure);
549 }551 }
550 const checks: PullRequestCheck[] = [];552 const checks: Array<{
553 readonly check: PullRequestCheck;
554 readonly workflowName: string | null;
555 readonly at: string | null;
556 }> = [];
551 for (const entry of decoded.success.values) {557 for (const entry of decoded.success.values) {
552 const decodedStatus = decodeStatusEntry(entry);558 const decodedStatus = decodeStatusEntry(entry);
553 if (Exit.isFailure(decodedStatus)) continue;559 if (Exit.isFailure(decodedStatus)) continue;
554 const status = decodedStatus.value;560 const status = decodedStatus.value;
555 const name = trimmed(status.name) ?? trimmed(status.key);561 const name = trimmed(status.name) ?? trimmed(status.key);
556 if (name === null) continue;562 if (name === null) continue;
563 // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear
564 // twice on one page. Nothing decoded here says which copy is newer, so the later one wins,
565 // which is the order Bitbucket writes an update in. The key is kept as the workflow name so
566 // two different pipelines that display the same name are not folded into one.
557 checks.push({567 checks.push({
558 name,568 check: {
559 status: toBuildStatus(status.state),569 name,
560 description: trimmed(status.description),570 status: toBuildStatus(status.state),
561 url: trimmed(status.url),571 description: trimmed(status.description),
572 url: trimmed(status.url),
573 },
574 workflowName: trimmed(status.key),
575 at: null,
562 });576 });
563 }577 }
564 return Result.succeed({ items: checks, next: trimmed(decoded.success.next) });578 return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) });
565}579}
566580
567export interface BitbucketDiffStat {581export interface BitbucketDiffStat {
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+380
...@@ -4,16 +4,20 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,16 +4,20 @@ import { describe, expect, it } from "vite-plus/test";
4import {4import {
5 buildReviewSubmissionJson,5 buildReviewSubmissionJson,
6 buildReviewerRequestJson,6 buildReviewerRequestJson,
7 decodeBaseComparisonJson,
7 decodePullRequestActivityJson,8 decodePullRequestActivityJson,
8 decodePullRequestDetailJson,9 decodePullRequestDetailJson,
9 decodePullRequestFilesJson,10 decodePullRequestFilesJson,
10 decodePullRequestListJson,11 decodePullRequestListJson,
12 decodePullRequestNodeIdJson,
13 decodePullRequestSearchJson,
11 decodeRepositoryAccessJson,14 decodeRepositoryAccessJson,
12 decodeReviewerCandidatesJson,15 decodeReviewerCandidatesJson,
13 decodeReviewThreadCommentsJson,16 decodeReviewThreadCommentsJson,
14 decodeReviewThreadsJson,17 decodeReviewThreadsJson,
15 decodeViewerPermissionsJson,18 decodeViewerPermissionsJson,
16 reviewThreadConversation,19 reviewThreadConversation,
20 REVIEW_THREADS_GRAPHQL_QUERY,
17} from "./gitHubPullRequestJson.ts";21} from "./gitHubPullRequestJson.ts";
1822
19function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {23function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
...@@ -67,6 +71,65 @@ describe("pull request list decoding", () => {...@@ -67,6 +71,65 @@ describe("pull request list decoding", () => {
67 expect(entry?.reviewRequestLogins).toEqual(["octocat"]);71 expect(entry?.reviewRequestLogins).toEqual(["octocat"]);
68 });72 });
6973
74 it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => {
75 const batch = expectSuccess(
76 decodePullRequestListJson(
77 listJson([
78 { reviewDecision: "APPROVED" },
79 { reviewDecision: "CHANGES_REQUESTED" },
80 { reviewDecision: "REVIEW_REQUIRED" },
81 { reviewDecision: null },
82 ]),
83 ),
84 );
85 expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([
86 "approved",
87 "changes-requested",
88 "review-required",
89 null,
90 ]);
91 });
92
93 it("rolls the head commit's checks up to the one word a row has space for", () => {
94 const batch = expectSuccess(
95 decodePullRequestListJson(
96 listJson([
97 // A failure outranks a run still going, and a completed run has to be read through its
98 // conclusion rather than its status.
99 {
100 statusCheckRollup: [
101 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
102 { name: "build", status: "IN_PROGRESS" },
103 { name: "test", status: "COMPLETED", conclusion: "FAILURE" },
104 ],
105 },
106 {
107 statusCheckRollup: [
108 { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" },
109 { name: "build", status: "QUEUED" },
110 ],
111 },
112 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] },
113 // A commit status reports one `state` and no `status` at all.
114 { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] },
115 // Neither a pass, a failure nor a wait is no verdict rather than a green tick.
116 { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] },
117 { statusCheckRollup: [] },
118 {},
119 ]),
120 ),
121 );
122 expect(batch.items.map((entry) => entry.checksState)).toEqual([
123 "failing",
124 "pending",
125 "passing",
126 "failing",
127 null,
128 null,
129 null,
130 ]);
131 });
132
70 it("skips malformed entries but still counts them, so paging does not stop early", () => {133 it("skips malformed entries but still counts them, so paging does not stop early", () => {
71 const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`;134 const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`;
72 const batch = expectSuccess(decodePullRequestListJson(raw));135 const batch = expectSuccess(decodePullRequestListJson(raw));
...@@ -75,6 +138,49 @@ describe("pull request list decoding", () => {...@@ -75,6 +138,49 @@ describe("pull request list decoding", () => {
75 });138 });
76});139});
77140
141describe("pull request search decoding", () => {
142 function searchJson(rollupStates: ReadonlyArray<string | null>): string {
143 return JSON.stringify({
144 data: {
145 search: {
146 pageInfo: { hasNextPage: false },
147 nodes: rollupStates.map((state, index) => ({
148 number: index + 1,
149 title: "Add the pull requests page",
150 url: "https://github.com/pingdotgg/t3code/pull/1",
151 headRefName: "feat/page",
152 baseRefName: "main",
153 createdAt: "2026-07-01T00:00:00Z",
154 updatedAt: "2026-07-02T00:00:00Z",
155 repository: { nameWithOwner: "pingdotgg/t3code" },
156 commits: {
157 nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }],
158 },
159 })),
160 },
161 },
162 });
163 }
164
165 it("maps the rollup enum the search answers with onto the same three words", () => {
166 // The search asks GitHub for the verdict rather than the checks behind it, so this path sees
167 // one enum where the listing sees an array.
168 const batch = expectSuccess(
169 decodePullRequestSearchJson(
170 searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]),
171 ),
172 );
173 expect(batch.items.map((entry) => entry.checksState)).toEqual([
174 "passing",
175 "failing",
176 "failing",
177 "pending",
178 "pending",
179 null,
180 ]);
181 });
182});
183
78describe("pull request detail decoding", () => {184describe("pull request detail decoding", () => {
79 const detailJson = JSON.stringify({185 const detailJson = JSON.stringify({
80 number: 7,186 number: 7,
...@@ -117,6 +223,58 @@ describe("pull request detail decoding", () => {...@@ -117,6 +223,58 @@ describe("pull request detail decoding", () => {
117 ]);223 ]);
118 });224 });
119225
226 it("reads an auto-merge request as armed, its null as off and its absence as neither", () => {
227 const raw = JSON.parse(detailJson) as Record<string, unknown>;
228 const armed = (entry: Record<string, unknown>) =>
229 expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry })))
230 .autoMergeEnabled;
231
232 expect(
233 armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }),
234 ).toBe(true);
235 expect(armed({ autoMergeRequest: null })).toBe(false);
236 // `gh` not answering for the field at all is not GitHub saying the merge is unarmed.
237 expect(armed({})).toBeUndefined();
238 });
239
240 it("shows a re-running check once, as the run that is happening now", () => {
241 // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice,
242 // the finished run and the one that replaced it, with no id to tell them apart.
243 const raw = JSON.parse(detailJson) as Record<string, unknown>;
244 const detail = expectSuccess(
245 decodePullRequestDetailJson(
246 JSON.stringify({
247 ...raw,
248 statusCheckRollup: [
249 {
250 __typename: "CheckRun",
251 name: "Prepare PR size config",
252 workflowName: "PR Size",
253 status: "COMPLETED",
254 conclusion: "SUCCESS",
255 startedAt: "2026-08-11T16:06:20Z",
256 completedAt: "2026-08-11T16:06:25Z",
257 },
258 {
259 __typename: "CheckRun",
260 name: "Prepare PR size config",
261 workflowName: "PR Size",
262 status: "IN_PROGRESS",
263 conclusion: "",
264 startedAt: "2026-08-11T17:01:04Z",
265 completedAt: "0001-01-01T00:00:00Z",
266 },
267 ],
268 }),
269 ),
270 );
271
272 expect(detail.checks.map((check) => [check.name, check.status])).toEqual([
273 ["Prepare PR size config", "pending"],
274 ]);
275 expect(detail.checksState).toBe("pending");
276 });
277
120 it("merges reviews with comments in time order and keeps a bodyless approval", () => {278 it("merges reviews with comments in time order and keeps a bodyless approval", () => {
121 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));279 const detail = expectSuccess(decodePullRequestActivityJson(detailJson));
122 // r2 approved without writing anything, which is still the event worth seeing.280 // r2 approved without writing anything, which is still the event worth seeing.
...@@ -461,6 +619,89 @@ describe("review thread decoding", () => {...@@ -461,6 +619,89 @@ describe("review thread decoding", () => {
461 });619 });
462});620});
463621
622describe("reaction decoding", () => {
623 const commentWithGroups = (reactionGroups: ReadonlyArray<Record<string, unknown>>) =>
624 JSON.stringify({
625 data: {
626 node: {
627 comments: {
628 pageInfo: { hasNextPage: false, endCursor: null },
629 nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }],
630 },
631 },
632 },
633 });
634
635 it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => {
636 const decoded = expectSuccess(
637 decodeReviewThreadCommentsJson(
638 commentWithGroups([
639 {
640 content: "THUMBS_UP",
641 viewerHasReacted: true,
642 reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] },
643 },
644 // Not one of the eight the contract carries.
645 {
646 content: "PARTY_PARROT",
647 reactors: { totalCount: 1, nodes: [{ login: "hubot" }] },
648 },
649 // Nobody behind it, which GitHub still answers a group for.
650 { content: "HEART", reactors: { totalCount: 0, nodes: [] } },
651 // More reactors than the bounded read named, and no `viewerHasReacted` at all.
652 {
653 content: "ROCKET",
654 reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] },
655 },
656 ]),
657 ),
658 );
659
660 expect(decoded.comments[0]?.reactions).toEqual([
661 { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true },
662 { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false },
663 ]);
664 });
665
666 it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => {
667 const decoded = expectSuccess(
668 decodeReviewThreadCommentsJson(
669 JSON.stringify({
670 data: {
671 viewer: { login: "Bilal" },
672 node: {
673 comments: {
674 pageInfo: { hasNextPage: false, endCursor: null },
675 nodes: [
676 {
677 id: "t1",
678 body: "nice",
679 createdAt: "2026-07-01T00:00:00Z",
680 reactionGroups: [
681 {
682 content: "HEART",
683 viewerHasReacted: true,
684 reactors: {
685 totalCount: 2,
686 nodes: [{ login: "bilal" }, { login: "julius" }],
687 },
688 },
689 ],
690 },
691 ],
692 },
693 },
694 },
695 }),
696 ),
697 );
698
699 expect(decoded.comments[0]?.reactions).toEqual([
700 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
701 ]);
702 });
703});
704
464describe("repository access decoding", () => {705describe("repository access decoding", () => {
465 const repositoryJson = (viewerPermission?: string | null) =>706 const repositoryJson = (viewerPermission?: string | null) =>
466 JSON.stringify({707 JSON.stringify({
...@@ -618,6 +859,7 @@ describe("review thread decoding", () => {...@@ -618,6 +859,7 @@ describe("review thread decoding", () => {
618 body: "first",859 body: "first",
619 createdAt: "2026-07-01T00:00:00Z",860 createdAt: "2026-07-01T00:00:00Z",
620 url: "https://github.com/acme/web/pull/1#discussion_rc1",861 url: "https://github.com/acme/web/pull/1#discussion_rc1",
862 reactions: [],
621 },863 },
622 {864 {
623 id: "c2",865 id: "c2",
...@@ -625,6 +867,7 @@ describe("review thread decoding", () => {...@@ -625,6 +867,7 @@ describe("review thread decoding", () => {
625 body: "second",867 body: "second",
626 createdAt: "2026-07-01T00:00:00Z",868 createdAt: "2026-07-01T00:00:00Z",
627 url: "https://github.com/acme/web/pull/1#discussion_rc2",869 url: "https://github.com/acme/web/pull/1#discussion_rc2",
870 reactions: [],
628 },871 },
629 ],872 ],
630 },873 },
...@@ -676,6 +919,101 @@ describe("review thread decoding", () => {...@@ -676,6 +919,101 @@ describe("review thread decoding", () => {
676 expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]);919 expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]);
677 expect(threads).toHaveLength(1);920 expect(threads).toHaveLength(1);
678 });921 });
922
923 it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => {
924 const result = expectSuccess(
925 decodeReviewThreadsJson(
926 threadsJson([], {
927 reactionGroups: [
928 {
929 content: "HEART",
930 viewerHasReacted: true,
931 reactors: { totalCount: 1, nodes: [{ login: "bilal" }] },
932 },
933 ],
934 comments: {
935 nodes: [
936 {
937 id: "c1",
938 reactionGroups: [
939 {
940 content: "THUMBS_UP",
941 reactors: { totalCount: 1, nodes: [{ login: "julius" }] },
942 },
943 ],
944 },
945 ],
946 },
947 reviews: {
948 nodes: [
949 {
950 id: "r1",
951 reactionGroups: [
952 { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } },
953 ],
954 },
955 ],
956 },
957 }),
958 ),
959 );
960
961 expect(result.reactions).toEqual([
962 { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true },
963 ]);
964 expect([...result.reactionsById]).toEqual([
965 ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]],
966 ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]],
967 ]);
968 });
969
970 it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => {
971 const result = expectSuccess(
972 decodeReviewThreadsJson(
973 JSON.stringify({
974 data: {
975 viewer: { login: "Bilal" },
976 repository: {
977 pullRequest: {
978 reviewThreads: { totalCount: 0, nodes: [] },
979 reactionGroups: [
980 {
981 content: "HEART",
982 viewerHasReacted: true,
983 reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] },
984 },
985 ],
986 },
987 },
988 },
989 }),
990 ),
991 );
992
993 expect(result.reactions).toEqual([
994 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
995 ]);
996 });
997});
998
999describe("decodePullRequestNodeIdJson", () => {
1000 it("reads the pull request's own node id, which a reaction on its description is addressed by", () => {
1001 expect(
1002 expectSuccess(
1003 decodePullRequestNodeIdJson(
1004 JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }),
1005 ),
1006 ),
1007 ).toBe("PR_kwDOA");
1008 });
1009});
1010
1011describe("REVIEW_THREADS_GRAPHQL_QUERY", () => {
1012 it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => {
1013 expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4);
1014 // The reviews connection is new: only reactions were ever wanted off it.
1015 expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:");
1016 });
679});1017});
6801018
681describe("reviewer candidate decoding", () => {1019describe("reviewer candidate decoding", () => {
...@@ -957,3 +1295,45 @@ describe("decodePullRequestFilesJson", () => {...@@ -957,3 +1295,45 @@ describe("decodePullRequestFilesJson", () => {
957 expect(result.truncated).toBe(false);1295 expect(result.truncated).toBe(false);
958 });1296 });
959});1297});
1298
1299describe("how far a branch trails its base", () => {
1300 const comparison = (pullRequest: unknown) =>
1301 JSON.stringify({ data: { repository: { pullRequest } } });
1302
1303 it("reads the commit count and whether this viewer may move the branch", () => {
1304 const decoded = expectSuccess(
1305 decodeBaseComparisonJson(
1306 comparison({ viewerCanUpdateBranch: true, baseRef: { compare: { behindBy: 12 } } }),
1307 ),
1308 );
1309 expect(decoded).toEqual({ behindBy: 12, viewerCanUpdate: true });
1310 });
1311
1312 it("reads a current branch as nothing to do", () => {
1313 expect(
1314 expectSuccess(
1315 decodeBaseComparisonJson(
1316 comparison({ viewerCanUpdateBranch: false, baseRef: { compare: { behindBy: 0 } } }),
1317 ),
1318 ),
1319 ).toEqual({ behindBy: 0, viewerCanUpdate: false });
1320 });
1321
1322 it("answers unknown where the head could not be compared", () => {
1323 // A pull request from a fork whose repository is gone, which GitHub answers with a null
1324 // comparison beside a perfectly good pull request.
1325 expect(
1326 expectSuccess(
1327 decodeBaseComparisonJson(comparison({ viewerCanUpdateBranch: true, baseRef: null })),
1328 ).behindBy,
1329 ).toBeNull();
1330 expect(expectSuccess(decodeBaseComparisonJson(comparison(null)))).toEqual({
1331 behindBy: null,
1332 viewerCanUpdate: false,
1333 });
1334 });
1335
1336 it("refuses a body that is not the answer to this question", () => {
1337 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
1338 });
1339});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+641-25
...@@ -6,12 +6,17 @@ import type {...@@ -6,12 +6,17 @@ import type {
6 PullRequestActor,6 PullRequestActor,
7 PullRequestCheck,7 PullRequestCheck,
8 PullRequestCheckStatus,8 PullRequestCheckStatus,
9 PullRequestChecksState,
9 PullRequestComment,10 PullRequestComment,
10 PullRequestCommit,11 PullRequestCommit,
11 PullRequestLabel,12 PullRequestLabel,
12 PullRequestMergeCapabilities,13 PullRequestMergeCapabilities,
14 PullRequestOmittedFileStat,
13 PullRequestMergeability,15 PullRequestMergeability,
16 PullRequestReaction,
17 PullRequestReactionContent,
14 PullRequestReviewCommentDraft,18 PullRequestReviewCommentDraft,
19 PullRequestReviewDecision,
15 PullRequestReviewThread,20 PullRequestReviewThread,
16 PullRequestReviewVerdict,21 PullRequestReviewVerdict,
17 PullRequestReviewerCandidate,22 PullRequestReviewerCandidate,
...@@ -22,6 +27,8 @@ import type {...@@ -22,6 +27,8 @@ import type {
22} from "@t3tools/contracts";27} from "@t3tools/contracts";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";28import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2429
30import { dedupeChecks } from "./pullRequestChecks.ts";
31
25/**32/**
26 * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh`33 * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh`
27 * release that adds a conclusion or a review state must not fail the whole payload.34 * release that adds a conclusion or a review state must not fail the whole payload.
...@@ -51,6 +58,27 @@ const RawReviewRequestSchema = Schema.Struct({...@@ -51,6 +58,27 @@ const RawReviewRequestSchema = Schema.Struct({
51 name: Schema.optional(Schema.NullOr(Schema.String)),58 name: Schema.optional(Schema.NullOr(Schema.String)),
52});59});
5360
61const RawCheckSchema = Schema.Struct({
62 __typename: Schema.optional(Schema.String),
63 name: Schema.optional(Schema.NullOr(Schema.String)),
64 context: Schema.optional(Schema.NullOr(Schema.String)),
65 status: Schema.optional(Schema.NullOr(Schema.String)),
66 conclusion: Schema.optional(Schema.NullOr(Schema.String)),
67 state: Schema.optional(Schema.NullOr(Schema.String)),
68 description: Schema.optional(Schema.NullOr(Schema.String)),
69 detailsUrl: Schema.optional(Schema.NullOr(Schema.String)),
70 targetUrl: Schema.optional(Schema.NullOr(Schema.String)),
71 /**
72 * What tells two same-named checks apart, and which run of one is the newest. All three ride
73 * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them
74 * costs no request. Empty for an app-provided check run, which belongs to no workflow, and
75 * absent entirely on a commit status, which is not a run at all.
76 */
77 workflowName: Schema.optional(Schema.NullOr(Schema.String)),
78 startedAt: Schema.optional(Schema.NullOr(Schema.String)),
79 completedAt: Schema.optional(Schema.NullOr(Schema.String)),
80});
81
54const RawListItemSchema = Schema.Struct({82const RawListItemSchema = Schema.Struct({
55 number: Schema.Int,83 number: Schema.Int,
56 title: Schema.String,84 title: Schema.String,
...@@ -61,6 +89,7 @@ const RawListItemSchema = Schema.Struct({...@@ -61,6 +89,7 @@ const RawListItemSchema = Schema.Struct({
61 state: Schema.optional(Schema.NullOr(Schema.String)),89 state: Schema.optional(Schema.NullOr(Schema.String)),
62 isDraft: Schema.optional(Schema.Boolean),90 isDraft: Schema.optional(Schema.Boolean),
63 mergeable: Schema.optional(Schema.NullOr(Schema.String)),91 mergeable: Schema.optional(Schema.NullOr(Schema.String)),
92 reviewDecision: Schema.optional(Schema.NullOr(Schema.String)),
64 additions: Schema.optional(Schema.Int),93 additions: Schema.optional(Schema.Int),
65 deletions: Schema.optional(Schema.Int),94 deletions: Schema.optional(Schema.Int),
66 createdAt: Schema.String,95 createdAt: Schema.String,
...@@ -68,6 +97,14 @@ const RawListItemSchema = Schema.Struct({...@@ -68,6 +97,14 @@ const RawListItemSchema = Schema.Struct({
68 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),97 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
69 reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)),98 reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)),
70 labels: Schema.optional(Schema.Array(RawLabelSchema)),99 labels: Schema.optional(Schema.Array(RawLabelSchema)),
100 /**
101 * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there
102 * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs
103 * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing
104 * reduces to one word. The listing pays it because the alternative is a request per row; the
105 * cross-repository search below asks GitHub for the verdict itself instead.
106 */
107 statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),
71});108});
72109
73/**110/**
...@@ -85,6 +122,7 @@ const RawSearchItemSchema = Schema.Struct({...@@ -85,6 +122,7 @@ const RawSearchItemSchema = Schema.Struct({
85 state: Schema.optional(Schema.NullOr(Schema.String)),122 state: Schema.optional(Schema.NullOr(Schema.String)),
86 isDraft: Schema.optional(Schema.Boolean),123 isDraft: Schema.optional(Schema.Boolean),
87 mergeable: Schema.optional(Schema.NullOr(Schema.String)),124 mergeable: Schema.optional(Schema.NullOr(Schema.String)),
125 reviewDecision: Schema.optional(Schema.NullOr(Schema.String)),
88 createdAt: Schema.String,126 createdAt: Schema.String,
89 updatedAt: Schema.String,127 updatedAt: Schema.String,
90 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),128 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
...@@ -113,6 +151,36 @@ const RawSearchItemSchema = Schema.Struct({...@@ -113,6 +151,36 @@ const RawSearchItemSchema = Schema.Struct({
113 }),151 }),
114 ),152 ),
115 ),153 ),
154 /**
155 * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather
156 * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across
157 * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`.
158 */
159 commits: Schema.optional(
160 Schema.NullOr(
161 Schema.Struct({
162 nodes: Schema.optional(
163 Schema.NullOr(
164 Schema.Array(
165 Schema.NullOr(
166 Schema.Struct({
167 commit: Schema.optional(
168 Schema.NullOr(
169 Schema.Struct({
170 statusCheckRollup: Schema.optional(
171 Schema.NullOr(Schema.Struct({ state: Schema.String })),
172 ),
173 }),
174 ),
175 ),
176 }),
177 ),
178 ),
179 ),
180 ),
181 }),
182 ),
183 ),
116});184});
117185
118const RawSearchSchema = Schema.Struct({186const RawSearchSchema = Schema.Struct({
...@@ -149,17 +217,109 @@ const RawStatsSchema = Schema.Struct({...@@ -149,17 +217,109 @@ const RawStatsSchema = Schema.Struct({
149 ),217 ),
150});218});
151219
152const RawCheckSchema = Schema.Struct({220/** How many of a reaction's people the hover names before it counts the rest. */
153 __typename: Schema.optional(Schema.String),221const REACTORS_PER_GROUP = 10;
154 name: Schema.optional(Schema.NullOr(Schema.String)),222
155 context: Schema.optional(Schema.NullOr(Schema.String)),223/**
156 status: Schema.optional(Schema.NullOr(Schema.String)),224 * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged:
157 conclusion: Schema.optional(Schema.NullOr(Schema.String)),225 * a hover says who reacted, and a hundred and forty names is a count, not a sentence.
158 state: Schema.optional(Schema.NullOr(Schema.String)),226 */
159 description: Schema.optional(Schema.NullOr(Schema.String)),227const REACTION_GROUPS_FIELDS = `reactionGroups {
160 detailsUrl: Schema.optional(Schema.NullOr(Schema.String)),228 content
161 targetUrl: Schema.optional(Schema.NullOr(Schema.String)),229 viewerHasReacted
162});230 reactors(first: ${REACTORS_PER_GROUP}) {
231 totalCount
232 nodes {
233 ... on User { login }
234 ... on Bot { login }
235 ... on Organization { login }
236 ... on Mannequin { login }
237 }
238 }
239}`;
240
241/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */
242const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = {
243 THUMBS_UP: "thumbs-up",
244 THUMBS_DOWN: "thumbs-down",
245 LAUGH: "laugh",
246 HOORAY: "hooray",
247 CONFUSED: "confused",
248 HEART: "heart",
249 ROCKET: "rocket",
250 EYES: "eyes",
251};
252
253const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
254 "thumbs-up": "THUMBS_UP",
255 "thumbs-down": "THUMBS_DOWN",
256 laugh: "LAUGH",
257 hooray: "HOORAY",
258 confused: "CONFUSED",
259 heart: "HEART",
260 rocket: "ROCKET",
261 eyes: "EYES",
262};
263
264export function gitHubReactionContent(content: PullRequestReactionContent): string {
265 return GITHUB_REACTION_BY_CONTENT[content];
266}
267
268const RawReactionGroupsSchema = Schema.optional(
269 Schema.NullOr(
270 Schema.Array(
271 Schema.Struct({
272 content: Schema.optional(Schema.NullOr(Schema.String)),
273 viewerHasReacted: Schema.optional(Schema.Boolean),
274 reactors: Schema.optional(
275 Schema.NullOr(
276 Schema.Struct({
277 totalCount: Schema.optional(Schema.Int),
278 nodes: Schema.optional(
279 Schema.NullOr(
280 Schema.Array(
281 Schema.NullOr(
282 Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }),
283 ),
284 ),
285 ),
286 ),
287 }),
288 ),
289 ),
290 }),
291 ),
292 ),
293);
294
295type RawReactionGroups = typeof RawReactionGroupsSchema.Type;
296
297/**
298 * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is
299 * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The
300 * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it
301 * in would name them twice — but `count` still counts them along with everyone else.
302 */
303function toReactions(
304 groups: RawReactionGroups,
305 viewer: string | null,
306): ReadonlyArray<PullRequestReaction> {
307 const normalizedViewer = viewer?.toLowerCase() ?? null;
308 const reactions: PullRequestReaction[] = [];
309 for (const group of groups ?? []) {
310 const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""];
311 if (content === undefined) continue;
312 const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []);
313 const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length);
314 if (count <= 0) continue;
315 const actors =
316 normalizedViewer === null
317 ? logins
318 : logins.filter((login) => login.toLowerCase() !== normalizedViewer);
319 reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true });
320 }
321 return reactions;
322}
163323
164const RawCommentSchema = Schema.Struct({324const RawCommentSchema = Schema.Struct({
165 id: Schema.String,325 id: Schema.String,
...@@ -167,6 +327,8 @@ const RawCommentSchema = Schema.Struct({...@@ -167,6 +327,8 @@ const RawCommentSchema = Schema.Struct({
167 body: Schema.optional(Schema.String),327 body: Schema.optional(Schema.String),
168 createdAt: Schema.String,328 createdAt: Schema.String,
169 url: Schema.optional(Schema.NullOr(Schema.String)),329 url: Schema.optional(Schema.NullOr(Schema.String)),
330 /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */
331 reactionGroups: RawReactionGroupsSchema,
170});332});
171333
172const RawReviewSchema = Schema.Struct({334const RawReviewSchema = Schema.Struct({
...@@ -196,10 +358,17 @@ const RawCommitSchema = Schema.Struct({...@@ -196,10 +358,17 @@ const RawCommitSchema = Schema.Struct({
196358
197const RawDetailSchema = Schema.Struct({359const RawDetailSchema = Schema.Struct({
198 ...RawListItemSchema.fields,360 ...RawListItemSchema.fields,
361 /** Names the fork a pull request came from, which is what qualifies its head ref. */
362 headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
199 body: Schema.optional(Schema.String),363 body: Schema.optional(Schema.String),
200 changedFiles: Schema.optional(Schema.Int),364 changedFiles: Schema.optional(Schema.Int),
201 closedAt: Schema.optional(Schema.NullOr(Schema.String)),365 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
202 statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),366 /**
367 * The standing instruction to merge once GitHub's own requirements are met, which is an object
368 * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read:
369 * the question the page asks is whether one exists.
370 */
371 autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)),
203});372});
204373
205const RawActivitySchema = Schema.Struct({374const RawActivitySchema = Schema.Struct({
...@@ -234,6 +403,11 @@ const RawThreadCommentsSchema = Schema.Struct({...@@ -234,6 +403,11 @@ const RawThreadCommentsSchema = Schema.Struct({
234/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */403/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */
235const RawReviewThreadsSchema = Schema.Struct({404const RawReviewThreadsSchema = Schema.Struct({
236 data: Schema.Struct({405 data: Schema.Struct({
406 // Rides along in the same request: GitHub names who reacted but never says whether that is
407 // the reader, so the comparison is made here rather than paid for with a request of its own.
408 viewer: Schema.optional(
409 Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })),
410 ),
237 repository: Schema.Struct({411 repository: Schema.Struct({
238 pullRequest: Schema.Struct({412 pullRequest: Schema.Struct({
239 reviewThreads: Schema.Struct({413 reviewThreads: Schema.Struct({
...@@ -254,11 +428,32 @@ const RawReviewThreadsSchema = Schema.Struct({...@@ -254,11 +428,32 @@ const RawReviewThreadsSchema = Schema.Struct({
254 }),428 }),
255 ...RawViewerFieldsSchema.fields,429 ...RawViewerFieldsSchema.fields,
256 author: Schema.optional(Schema.NullOr(RawActorSchema)),430 author: Schema.optional(Schema.NullOr(RawActorSchema)),
431 reactionGroups: RawReactionGroupsSchema,
257 comments: Schema.optional(432 comments: Schema.optional(
258 Schema.NullOr(433 Schema.NullOr(
259 Schema.Struct({434 Schema.Struct({
260 nodes: Schema.Array(435 nodes: Schema.Array(
261 Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }),436 Schema.Struct({
437 id: Schema.optional(Schema.NullOr(Schema.String)),
438 author: Schema.optional(Schema.NullOr(RawActorSchema)),
439 reactionGroups: RawReactionGroupsSchema,
440 }),
441 ),
442 }),
443 ),
444 ),
445 /**
446 * Reviews for their reactions alone: the words and the verdict arrive with
447 * `gh pr view --json reviews`, which reports no reaction of any kind.
448 */
449 reviews: Schema.optional(
450 Schema.NullOr(
451 Schema.Struct({
452 nodes: Schema.Array(
453 Schema.Struct({
454 id: Schema.optional(Schema.NullOr(Schema.String)),
455 reactionGroups: RawReactionGroupsSchema,
456 }),
262 ),457 ),
263 }),458 }),
264 ),459 ),
...@@ -284,6 +479,23 @@ const RawReviewThreadsSchema = Schema.Struct({...@@ -284,6 +479,23 @@ const RawReviewThreadsSchema = Schema.Struct({
284 }),479 }),
285 ),480 ),
286 ),481 ),
482 reviewDismissals: Schema.optional(
483 Schema.NullOr(
484 Schema.Struct({
485 pageInfo: Schema.optional(RawPageInfoSchema),
486 nodes: Schema.Array(
487 Schema.Struct({
488 dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)),
489 review: Schema.optional(
490 Schema.NullOr(
491 Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }),
492 ),
493 ),
494 }),
495 ),
496 }),
497 ),
498 ),
287 commits: Schema.optional(499 commits: Schema.optional(
288 Schema.NullOr(500 Schema.NullOr(
289 Schema.Struct({501 Schema.Struct({
...@@ -384,9 +596,9 @@ export function decodeActorAvatarsJson(...@@ -384,9 +596,9 @@ export function decodeActorAvatarsJson(
384}596}
385597
386export const PULL_REQUEST_LIST_JSON_FIELDS =598export const PULL_REQUEST_LIST_JSON_FIELDS =
387 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels";599 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup";
388600
389export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`;601export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`;
390export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";602export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";
391603
392/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */604/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
...@@ -429,12 +641,14 @@ export function pullRequestSearchGraphQlQuery(rows: number): string {...@@ -429,12 +641,14 @@ export function pullRequestSearchGraphQlQuery(rows: number): string {
429 state641 state
430 isDraft642 isDraft
431 mergeable643 mergeable
644 reviewDecision
432 createdAt645 createdAt
433 updatedAt646 updatedAt
434 mergedAt647 mergedAt
435 repository { nameWithOwner }648 repository { nameWithOwner }
436 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } }649 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } }
437 labels(first: 20) { nodes { name color } }650 labels(first: 20) { nodes { name color } }
651 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
438 }652 }
439 }653 }
440 }654 }
...@@ -461,6 +675,7 @@ export function pullRequestSearchGraphQlQuery(rows: number): string {...@@ -461,6 +675,7 @@ export function pullRequestSearchGraphQlQuery(rows: number): string {
461 * wants, and stands in for the `gh` list wherever it came back non-empty.675 * wants, and stands in for the `gh` list wherever it came back non-empty.
462 */676 */
463export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {677export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
678 viewer { login }
464 repository(owner: $owner, name: $name) {679 repository(owner: $owner, name: $name) {
465 pullRequest(number: $number) {680 pullRequest(number: $number) {
466 reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {681 reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
...@@ -476,14 +691,18 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin...@@ -476,14 +691,18 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
476 comments(first: ${GRAPHQL_PAGE_SIZE}) {691 comments(first: ${GRAPHQL_PAGE_SIZE}) {
477 totalCount692 totalCount
478 pageInfo { hasNextPage endCursor }693 pageInfo { hasNextPage endCursor }
479 nodes { id author { login avatarUrl } body createdAt url }694 nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
480 }695 }
481 }696 }
482 }697 }
483 viewerCanUpdate698 viewerCanUpdate
484 viewerDidAuthor699 viewerDidAuthor
485 author { login avatarUrl }700 author { login avatarUrl }
486 comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } }701 ${REACTION_GROUPS_FIELDS}
702 comments(first: ${GRAPHQL_PAGE_SIZE}) {
703 nodes { id author { login avatarUrl } ${REACTION_GROUPS_FIELDS} }
704 }
705 reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id ${REACTION_GROUPS_FIELDS} } }
487 reviewRequests(first: 50) {706 reviewRequests(first: 50) {
488 nodes {707 nodes {
489 requestedReviewer {708 requestedReviewer {
...@@ -495,6 +714,10 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin...@@ -495,6 +714,10 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
495 latestReviews(first: 50) {714 latestReviews(first: 50) {
496 nodes { author { login avatarUrl } }715 nodes { author { login avatarUrl } }
497 }716 }
717 reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) {
718 pageInfo { hasNextPage endCursor }
719 nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } }
720 }
498 commits(last: ${GRAPHQL_PAGE_SIZE}) {721 commits(last: ${GRAPHQL_PAGE_SIZE}) {
499 nodes {722 nodes {
500 commit {723 commit {
...@@ -517,11 +740,12 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin...@@ -517,11 +740,12 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin
517 * GitHub makes necessary, and one no ordinary pull request ever provokes.740 * GitHub makes necessary, and one no ordinary pull request ever provokes.
518 */741 */
519export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) {742export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) {
743 viewer { login }
520 node(id: $threadId) {744 node(id: $threadId) {
521 ... on PullRequestReviewThread {745 ... on PullRequestReviewThread {
522 comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {746 comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
523 pageInfo { hasNextPage endCursor }747 pageInfo { hasNextPage endCursor }
524 nodes { id author { login avatarUrl } body createdAt url }748 nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} }
525 }749 }
526 }750 }
527 }751 }
...@@ -529,6 +753,9 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $curs...@@ -529,6 +753,9 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $curs
529753
530const RawReviewThreadCommentsSchema = Schema.Struct({754const RawReviewThreadCommentsSchema = Schema.Struct({
531 data: Schema.Struct({755 data: Schema.Struct({
756 viewer: Schema.optional(
757 Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })),
758 ),
532 /** Null for an id that names nothing the viewer can read, which is not a thread to page. */759 /** Null for an id that names nothing the viewer can read, which is not a thread to page. */
533 node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })),760 node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })),
534 }),761 }),
...@@ -540,6 +767,84 @@ export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $b...@@ -540,6 +767,84 @@ export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $b
540 }767 }
541}`;768}`;
542769
770/**
771 * The pull request's own node id, which is what a reaction on its description is addressed by.
772 * Read only when one is being written: the conversation carries an id for every remark in it, and
773 * the pull request is the one subject nothing in it names.
774 */
775export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
776 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
777}`;
778
779const RawPullRequestNodeIdSchema = Schema.Struct({
780 data: Schema.Struct({
781 repository: Schema.Struct({
782 pullRequest: Schema.Struct({ id: Schema.String }),
783 }),
784 }),
785});
786
787const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema);
788
789export function decodePullRequestNodeIdJson(raw: string): Result.Result<string, DecodeFailure> {
790 const decoded = decodePullRequestNodeId(raw);
791 return Result.isSuccess(decoded)
792 ? Result.succeed(decoded.success.data.repository.pullRequest.id)
793 : Result.fail(decoded.failure);
794}
795
796/**
797 * Where a client-given reaction subject actually hangs: the pull request itself, or the pull
798 * request an issue comment, a review comment, or a review belongs to. Read before a mutation
799 * reaches it, so a subject named for one pull request cannot react on another's behalf.
800 */
801export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) {
802 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
803 node(id: $subjectId) {
804 id
805 ... on IssueComment { pullRequest { id } }
806 ... on PullRequestReviewComment { pullRequest { id } }
807 ... on PullRequestReview { pullRequest { id } }
808 }
809}`;
810
811const RawReactionSubjectScopeSchema = Schema.Struct({
812 data: Schema.Struct({
813 repository: Schema.NullOr(
814 Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }),
815 ),
816 node: Schema.NullOr(
817 Schema.Struct({
818 id: Schema.String,
819 pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })),
820 }),
821 ),
822 }),
823});
824
825const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema);
826
827/**
828 * True when the subject named is the pull request itself, or hangs off it — false for anything
829 * else, including a subject or a pull request this host could not find.
830 */
831export function decodeReactionSubjectScopeJson(raw: string): Result.Result<boolean, DecodeFailure> {
832 const decoded = decodeReactionSubjectScope(raw);
833 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
834 const expected = decoded.success.data.repository?.pullRequest?.id ?? null;
835 const node = decoded.success.data.node;
836 const actual = node === null ? null : (node.pullRequest?.id ?? node.id);
837 return Result.succeed(expected !== null && actual !== null && expected === actual);
838}
839
840export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
841 addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
842}`;
843
844export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
845 removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
846}`;
847
543export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {848export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {
544 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }849 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
545}`;850}`;
...@@ -548,6 +853,31 @@ export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!...@@ -548,6 +853,31 @@ export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!
548 unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }853 unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
549}`;854}`;
550855
856/**
857 * Rewrites the pull request's own words. Both are nullable so that one document serves a change
858 * to the title, to the description, or to the two together: a variable the request does not send
859 * puts no entry in the input at all, which leaves that field as it was rather than clearing it.
860 */
861export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) {
862 updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) {
863 pullRequest { id }
864 }
865}`;
866
867/**
868 * The two comment mutations name their comment differently. The variable is spelled the same in
869 * both, so a rewrite sends one set of variables whichever kind of remark it is.
870 */
871export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
872 updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } }
873}`;
874
875export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
876 updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) {
877 pullRequestReviewComment { id }
878 }
879}`;
880
551/**881/**
552 * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document882 * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document
553 * rather than as `-f name=value` flags, so a reader's own words never reach argv.883 * rather than as `-f name=value` flags, so a reader's own words never reach argv.
...@@ -588,6 +918,21 @@ const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "R...@@ -588,6 +918,21 @@ const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "R
588 "request-changes": "REQUEST_CHANGES",918 "request-changes": "REQUEST_CHANGES",
589};919};
590920
921/**
922 * The dismissal events past the page the thread read carries. A pull request rarely has any:
923 * this is followed only while the embedded page reports more.
924 */
925export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
926 repository(owner: $owner, name: $name) {
927 pullRequest(number: $number) {
928 timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
929 pageInfo { hasNextPage endCursor }
930 nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } }
931 }
932 }
933 }
934}`;
935
591/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */936/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */
592export function buildReviewSubmissionJson(input: {937export function buildReviewSubmissionJson(input: {
593 readonly verdict: PullRequestReviewVerdict;938 readonly verdict: PullRequestReviewVerdict;
...@@ -626,6 +971,8 @@ export interface GitHubPullRequestListItem {...@@ -626,6 +971,8 @@ export interface GitHubPullRequestListItem {
626 readonly state: PullRequestState;971 readonly state: PullRequestState;
627 readonly isDraft: boolean;972 readonly isDraft: boolean;
628 readonly mergeability: PullRequestMergeability;973 readonly mergeability: PullRequestMergeability;
974 /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */
975 readonly reviewDecision: PullRequestReviewDecision | null;
629 readonly additions: number;976 readonly additions: number;
630 readonly deletions: number;977 readonly deletions: number;
631 readonly createdAt: string;978 readonly createdAt: string;
...@@ -634,14 +981,20 @@ export interface GitHubPullRequestListItem {...@@ -634,14 +981,20 @@ export interface GitHubPullRequestListItem {
634 /** At least one outstanding request targets a team rather than an individual login. */981 /** At least one outstanding request targets a team rather than an individual login. */
635 readonly hasTeamReviewRequest: boolean;982 readonly hasTeamReviewRequest: boolean;
636 readonly labels: ReadonlyArray<PullRequestLabel>;983 readonly labels: ReadonlyArray<PullRequestLabel>;
984 /** Null where the head commit reported no checks, which is not the same as passing none. */
985 readonly checksState: PullRequestChecksState | null;
637}986}
638987
639export interface GitHubPullRequestDetail extends GitHubPullRequestListItem {988export interface GitHubPullRequestDetail extends GitHubPullRequestListItem {
989 /** The owner of the head branch's repository; null where `gh` did not say. */
990 readonly headRepositoryOwner: string | null;
640 readonly body: string;991 readonly body: string;
641 readonly changedFiles: number;992 readonly changedFiles: number;
642 readonly mergedAt: string | null;993 readonly mergedAt: string | null;
643 readonly closedAt: string | null;994 readonly closedAt: string | null;
644 readonly checks: ReadonlyArray<PullRequestCheck>;995 readonly checks: ReadonlyArray<PullRequestCheck>;
996 /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */
997 readonly autoMergeEnabled?: boolean;
645}998}
646999
647export interface GitHubPullRequestActivity {1000export interface GitHubPullRequestActivity {
...@@ -731,6 +1084,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili...@@ -731,6 +1084,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili
731 }1084 }
732}1085}
7331086
1087function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null {
1088 switch (value?.trim().toUpperCase()) {
1089 case "APPROVED":
1090 return "approved";
1091 case "CHANGES_REQUESTED":
1092 return "changes-requested";
1093 case "REVIEW_REQUIRED":
1094 return "review-required";
1095 default:
1096 return null;
1097 }
1098}
1099
734function toLabels(1100function toLabels(
735 raw: ReadonlyArray<Schema.Schema.Type<typeof RawLabelSchema>> | undefined,1101 raw: ReadonlyArray<Schema.Schema.Type<typeof RawLabelSchema>> | undefined,
736): ReadonlyArray<PullRequestLabel> {1102): ReadonlyArray<PullRequestLabel> {
...@@ -792,23 +1158,81 @@ function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequ...@@ -792,23 +1158,81 @@ function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequ
792 }1158 }
793}1159}
7941160
795function toChecks(1161/** What GitHub writes where a run has not reached that moment yet, which is not a time. */
1162const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z";
1163
1164function realTimestamp(value: string | null | undefined): string | null {
1165 const at = trimmed(value);
1166 return at === null || at === UNSET_TIMESTAMP ? null : at;
1167}
1168
1169/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */
1170function isNamelessCheck(raw: Schema.Schema.Type<typeof RawCheckSchema>): boolean {
1171 return trimmed(raw.name) === null && trimmed(raw.context) === null;
1172}
1173
1174/**
1175 * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last
1176 * had something to say. A queued run reports a completion time it has not reached, so the start
1177 * stands in for it rather than sorting the newest run to the bottom.
1178 */
1179function toCheckEntries(
796 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,1180 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
797): ReadonlyArray<PullRequestCheck> {1181): ReadonlyArray<{
1182 readonly check: PullRequestCheck;
1183 readonly workflowName: string | null;
1184 readonly at: string | null;
1185}> {
798 return (raw ?? []).flatMap((check) => {1186 return (raw ?? []).flatMap((check) => {
799 const name = trimmed(check.name) ?? trimmed(check.context);1187 const name = trimmed(check.name) ?? trimmed(check.context);
800 if (name === null) return [];1188 if (name === null) return [];
801 return [1189 return [
802 {1190 {
803 name,1191 check: {
804 status: toCheckStatus(check),1192 name,
805 description: trimmed(check.description),1193 status: toCheckStatus(check),
806 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),1194 description: trimmed(check.description),
1195 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),
1196 },
1197 workflowName: trimmed(check.workflowName),
1198 at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt),
807 },1199 },
808 ];1200 ];
809 });1201 });
810}1202}
8111203
1204/**
1205 * The one word a listing row has space for. A failure outranks anything still running, the way
1206 * GitHub's own indicator reads: a run that has already gone red will not go green by finishing.
1207 *
1208 * Null rather than "passing" for a head commit with no checks at all, so a repository that runs
1209 * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a
1210 * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither.
1211 *
1212 * Counted off the deduped checks rather than the raw rollup, so the word and the list under it
1213 * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is
1214 * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one
1215 * nameless row, and nothing nameless can collide with anything.
1216 */
1217function rollupChecksState(
1218 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
1219): PullRequestChecksState | null {
1220 const statuses = [
1221 ...toChecks(raw).map((check) => check.status),
1222 ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)),
1223 ];
1224 if (statuses.length === 0) return null;
1225 if (statuses.includes("failure")) return "failing";
1226 if (statuses.includes("pending")) return "pending";
1227 return statuses.includes("success") ? "passing" : null;
1228}
1229
1230function toChecks(
1231 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
1232): ReadonlyArray<PullRequestCheck> {
1233 return dedupeChecks(toCheckEntries(raw));
1234}
1235
812/** The states that are a verdict in themselves, rather than a wrapper around line comments. */1236/** The states that are a verdict in themselves, rather than a wrapper around line comments. */
813function isReviewVerdict(reviewState: string | null): boolean {1237function isReviewVerdict(reviewState: string | null): boolean {
814 switch (reviewState?.toUpperCase()) {1238 switch (reviewState?.toUpperCase()) {
...@@ -894,6 +1318,7 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu...@@ -894,6 +1318,7 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu
894 state: toState(raw),1318 state: toState(raw),
895 isDraft: raw.isDraft ?? false,1319 isDraft: raw.isDraft ?? false,
896 mergeability: toMergeability(raw.mergeable),1320 mergeability: toMergeability(raw.mergeable),
1321 reviewDecision: toReviewDecision(raw.reviewDecision),
897 additions: raw.additions ?? 0,1322 additions: raw.additions ?? 0,
898 deletions: raw.deletions ?? 0,1323 deletions: raw.deletions ?? 0,
899 createdAt: raw.createdAt,1324 createdAt: raw.createdAt,
...@@ -901,17 +1326,24 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu...@@ -901,17 +1326,24 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu
901 reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests),1326 reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests),
902 hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests),1327 hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests),
903 labels: toLabels(raw.labels),1328 labels: toLabels(raw.labels),
1329 checksState: rollupChecksState(raw.statusCheckRollup),
904 };1330 };
905}1331}
9061332
907function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail {1333function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail {
908 return {1334 return {
909 ...toListItem(raw),1335 ...toListItem(raw),
1336 headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login),
910 body: raw.body ?? "",1337 body: raw.body ?? "",
911 changedFiles: raw.changedFiles ?? 0,1338 changedFiles: raw.changedFiles ?? 0,
912 mergedAt: trimmed(raw.mergedAt),1339 mergedAt: trimmed(raw.mergedAt),
913 closedAt: trimmed(raw.closedAt),1340 closedAt: trimmed(raw.closedAt),
914 checks: toChecks(raw.statusCheckRollup),1341 checks: toChecks(raw.statusCheckRollup),
1342 // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and
1343 // the difference survives here rather than being flattened into false.
1344 ...(raw.autoMergeRequest === undefined
1345 ? {}
1346 : { autoMergeEnabled: raw.autoMergeRequest !== null }),
915 };1347 };
916}1348}
9171349
...@@ -1006,6 +1438,12 @@ export function decodePullRequestSearchJson(...@@ -1006,6 +1438,12 @@ export function decodePullRequestSearchJson(
1006 return login === null ? [] : [{ login }];1438 return login === null ? [] : [{ login }];
1007 }),1439 }),
1008 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),1440 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),
1441 // The search asks for the verdict rather than the checks behind it, so it arrives as one
1442 // enum. Dressed as a single check here so the rollup is read the same way on both paths.
1443 statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => {
1444 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1445 return state === null ? [] : [{ state }];
1446 }),
1009 }),1447 }),
1010 repository,1448 repository,
1011 });1449 });
...@@ -1094,11 +1532,17 @@ export function decodePullRequestActivityJson(...@@ -1094,11 +1532,17 @@ export function decodePullRequestActivityJson(
10941532
1095export interface GitHubReviewThreadComments {1533export interface GitHubReviewThreadComments {
1096 readonly comments: ReadonlyArray<PullRequestComment>;1534 readonly comments: ReadonlyArray<PullRequestComment>;
1535 /** Dismissal reasons by the dismissed review's node id, read off the timeline. */
1536 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
1097 /** Whole conversations, kept anchored so the diff can pin them to their line. */1537 /** Whole conversations, kept anchored so the diff can pin them to their line. */
1098 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;1538 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
1099 /** The host's own count of the conversation, which a bounded read can fall short of. */1539 /** The host's own count of the conversation, which a bounded read can fall short of. */
1100 readonly commentCount: number;1540 readonly commentCount: number;
1101 readonly truncated: boolean;1541 readonly truncated: boolean;
1542 /** The pull request's own reactions, which sit on its description. */
1543 readonly reactions: ReadonlyArray<PullRequestReaction>;
1544 /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */
1545 readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
1102 /**1546 /**
1103 * Everyone on the review: those still asked and those who have already answered. Whoever has1547 * Everyone on the review: those still asked and those who have already answered. Whoever has
1104 * reviewed is no longer an outstanding request, so asking only for requests reports nobody on1548 * reviewed is no longer an outstanding request, so asking only for requests reports nobody on
...@@ -1140,6 +1584,13 @@ export interface GitHubReviewThreadPage {...@@ -1140,6 +1584,13 @@ export interface GitHubReviewThreadPage {
1140 readonly threads: ReadonlyArray<GitHubReviewThreadEntry>;1584 readonly threads: ReadonlyArray<GitHubReviewThreadEntry>;
1141 /** Where the next page of threads starts, or null once the host has handed them all over. */1585 /** Where the next page of threads starts, or null once the host has handed them all over. */
1142 readonly nextCursor: string | null;1586 readonly nextCursor: string | null;
1587 /** The pull request's own reactions, which sit on its description. */
1588 readonly reactions: ReadonlyArray<PullRequestReaction>;
1589 /**
1590 * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers
1591 * for without any. Only ids with a reaction are here; the rest carry none.
1592 */
1593 readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
1143 readonly reviewers: ReadonlyArray<PullRequestActor>;1594 readonly reviewers: ReadonlyArray<PullRequestActor>;
1144 readonly avatarsByLogin: ReadonlyMap<string, string>;1595 readonly avatarsByLogin: ReadonlyMap<string, string>;
1145 readonly commitStats: ReadonlyMap<1596 readonly commitStats: ReadonlyMap<
...@@ -1148,6 +1599,10 @@ export interface GitHubReviewThreadPage {...@@ -1148,6 +1599,10 @@ export interface GitHubReviewThreadPage {
1148 >;1599 >;
1149 readonly commits: ReadonlyArray<PullRequestCommit>;1600 readonly commits: ReadonlyArray<PullRequestCommit>;
1150 readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean };1601 readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean };
1602 /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */
1603 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
1604 /** Where the rest of the dismissal events start, or null once this page carried them all. */
1605 readonly nextDismissalCursor: string | null;
1151}1606}
11521607
1153/**1608/**
...@@ -1169,12 +1624,71 @@ export function reviewThreadConversation(...@@ -1169,12 +1624,71 @@ export function reviewThreadConversation(
1169 url: comment.url,1624 url: comment.url,
1170 path: thread.path,1625 path: thread.path,
1171 reviewState: null,1626 reviewState: null,
1627 reactions: comment.reactions ?? [],
1172 }),1628 }),
1173 ),1629 ),
1174 );1630 );
1175}1631}
11761632
1177/** One page of review threads. Following the cursors it hands back is the caller's job. */1633/** One page of review threads. Following the cursors it hands back is the caller's job. */
1634function toDismissalEntries(
1635 nodes:
1636 | ReadonlyArray<{
1637 readonly dismissalMessage?: string | null | undefined;
1638 readonly review?: { readonly id?: string | null | undefined } | null | undefined;
1639 }>
1640 | undefined,
1641): Map<string, string> {
1642 const entries = new Map<string, string>();
1643 for (const node of nodes ?? []) {
1644 const reviewId = trimmed(node.review?.id);
1645 const message = trimmed(node.dismissalMessage);
1646 if (reviewId !== null && message !== null) entries.set(reviewId, message);
1647 }
1648 return entries;
1649}
1650
1651const RawReviewDismissalsSchema = Schema.Struct({
1652 data: Schema.Struct({
1653 repository: Schema.Struct({
1654 pullRequest: Schema.Struct({
1655 timelineItems: Schema.Struct({
1656 pageInfo: Schema.optional(RawPageInfoSchema),
1657 nodes: Schema.Array(
1658 Schema.Struct({
1659 dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)),
1660 review: Schema.optional(
1661 Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })),
1662 ),
1663 }),
1664 ),
1665 }),
1666 }),
1667 }),
1668 }),
1669});
1670
1671const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema);
1672
1673/** One further page of dismissal events, in the shape the thread read's own page carries. */
1674export function decodeReviewDismissalsJson(raw: string): Result.Result<
1675 {
1676 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
1677 readonly nextCursor: string | null;
1678 },
1679 DecodeFailure
1680> {
1681 const decoded = decodeReviewDismissals(raw);
1682 if (!Result.isSuccess(decoded)) {
1683 return Result.fail(decoded.failure);
1684 }
1685 const items = decoded.success.data.repository.pullRequest.timelineItems;
1686 return Result.succeed({
1687 dismissalsByReviewId: toDismissalEntries(items.nodes),
1688 nextCursor: nextCursorOf(items.pageInfo),
1689 });
1690}
1691
1178export function decodeReviewThreadsJson(1692export function decodeReviewThreadsJson(
1179 raw: string,1693 raw: string,
1180): Result.Result<GitHubReviewThreadPage, DecodeFailure> {1694): Result.Result<GitHubReviewThreadPage, DecodeFailure> {
...@@ -1182,6 +1696,7 @@ export function decodeReviewThreadsJson(...@@ -1182,6 +1696,7 @@ export function decodeReviewThreadsJson(
1182 if (!Result.isSuccess(decoded)) {1696 if (!Result.isSuccess(decoded)) {
1183 return Result.fail(decoded.failure);1697 return Result.fail(decoded.failure);
1184 }1698 }
1699 const viewer = trimmed(decoded.success.data.viewer?.login);
1185 const threads = decoded.success.data.repository.pullRequest.reviewThreads;1700 const threads = decoded.success.data.repository.pullRequest.reviewThreads;
1186 const entries = threads.nodes.flatMap((thread): ReadonlyArray<GitHubReviewThreadEntry> => {1701 const entries = threads.nodes.flatMap((thread): ReadonlyArray<GitHubReviewThreadEntry> => {
1187 const path = trimmed(thread.path);1702 const path = trimmed(thread.path);
...@@ -1207,6 +1722,7 @@ export function decodeReviewThreadsJson(...@@ -1207,6 +1722,7 @@ export function decodeReviewThreadsJson(
1207 body: comment.body ?? "",1722 body: comment.body ?? "",
1208 createdAt: comment.createdAt,1723 createdAt: comment.createdAt,
1209 url: trimmed(comment.url),1724 url: trimmed(comment.url),
1725 reactions: toReactions(comment.reactionGroups, viewer),
1210 })),1726 })),
1211 },1727 },
1212 commentCount: thread.comments.totalCount ?? thread.comments.nodes.length,1728 commentCount: thread.comments.totalCount ?? thread.comments.nodes.length,
...@@ -1260,14 +1776,28 @@ export function decodeReviewThreadsJson(...@@ -1260,14 +1776,28 @@ export function decodeReviewThreadsJson(
1260 }),1776 }),
1261 });1777 });
1262 }1778 }
1779 const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
1780 for (const node of [
1781 ...(pullRequest.comments?.nodes ?? []),
1782 ...(pullRequest.reviews?.nodes ?? []),
1783 ]) {
1784 const id = trimmed(node.id);
1785 if (id === null) continue;
1786 const reactions = toReactions(node.reactionGroups, viewer);
1787 if (reactions.length > 0) reactionsById.set(id, reactions);
1788 }
1263 return Result.succeed({1789 return Result.succeed({
1264 threads: entries,1790 threads: entries,
1265 nextCursor: nextCursorOf(threads.pageInfo),1791 nextCursor: nextCursorOf(threads.pageInfo),
1792 reactions: toReactions(pullRequest.reactionGroups, viewer),
1793 reactionsById,
1266 reviewers: [...reviewers.values()],1794 reviewers: [...reviewers.values()],
1267 avatarsByLogin,1795 avatarsByLogin,
1268 commitStats,1796 commitStats,
1269 commits,1797 commits,
1270 viewer: toPullRequestViewerFields(pullRequest),1798 viewer: toPullRequestViewerFields(pullRequest),
1799 dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes),
1800 nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo),
1271 });1801 });
1272}1802}
12731803
...@@ -1283,6 +1813,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<...@@ -1283,6 +1813,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
1283 if (!Result.isSuccess(decoded)) {1813 if (!Result.isSuccess(decoded)) {
1284 return Result.fail(decoded.failure);1814 return Result.fail(decoded.failure);
1285 }1815 }
1816 const viewer = trimmed(decoded.success.data.viewer?.login);
1286 const comments = decoded.success.data.node?.comments;1817 const comments = decoded.success.data.node?.comments;
1287 return Result.succeed({1818 return Result.succeed({
1288 comments: (comments?.nodes ?? []).map((comment) => ({1819 comments: (comments?.nodes ?? []).map((comment) => ({
...@@ -1291,6 +1822,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<...@@ -1291,6 +1822,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
1291 body: comment.body ?? "",1822 body: comment.body ?? "",
1292 createdAt: comment.createdAt,1823 createdAt: comment.createdAt,
1293 url: trimmed(comment.url),1824 url: trimmed(comment.url),
1825 reactions: toReactions(comment.reactionGroups, viewer),
1294 })),1826 })),
1295 nextCursor: nextCursorOf(comments?.pageInfo),1827 nextCursor: nextCursorOf(comments?.pageInfo),
1296 });1828 });
...@@ -1351,6 +1883,75 @@ export function decodeRepositoryAccessJson(...@@ -1351,6 +1883,75 @@ export function decodeRepositoryAccessJson(
1351 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses1883 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses
1352 * fails whole, taking the people down with the teams.1884 * fails whole, taking the people down with the teams.
1353 */1885 */
1886/**
1887 * Where the branch stands against its base, and whether this viewer may move it.
1888 *
1889 * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the repository requires
1890 * branches to be up to date before merging, so on every other repository a stale branch reads as
1891 * CLEAN or BLOCKED like any other. The comparison counts the commits instead, which is the same
1892 * number GitHub's own "out-of-date" banner shows.
1893 *
1894 * `headRef` is qualified `owner:branch` because a pull request from a fork has no branch of that
1895 * name in the base repository, and an unqualified name is simply not found there.
1896 */
1897export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
1898 repository(owner: $owner, name: $name) {
1899 pullRequest(number: $number) {
1900 viewerCanUpdateBranch
1901 baseRef {
1902 compare(headRef: $headRef) {
1903 behindBy
1904 }
1905 }
1906 }
1907 }
1908}`;
1909
1910const RawBaseComparisonSchema = Schema.Struct({
1911 data: Schema.Struct({
1912 repository: Schema.NullOr(
1913 Schema.Struct({
1914 pullRequest: Schema.NullOr(
1915 Schema.Struct({
1916 viewerCanUpdateBranch: Schema.optional(Schema.NullOr(Schema.Boolean)),
1917 /** Null where the head repository is gone, which is a comparison nobody can make. */
1918 baseRef: Schema.optional(
1919 Schema.NullOr(
1920 Schema.Struct({
1921 compare: Schema.optional(
1922 Schema.NullOr(Schema.Struct({ behindBy: Schema.Number })),
1923 ),
1924 }),
1925 ),
1926 ),
1927 }),
1928 ),
1929 }),
1930 ),
1931 }),
1932});
1933
1934const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema);
1935
1936export interface GitHubBaseComparison {
1937 /** Null where the host could not compare, which the page reads as "unknown". */
1938 readonly behindBy: number | null;
1939 readonly viewerCanUpdate: boolean;
1940}
1941
1942export function decodeBaseComparisonJson(
1943 raw: string,
1944): Result.Result<GitHubBaseComparison, DecodeFailure> {
1945 const decoded = decodeBaseComparison(raw);
1946 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1947 const pullRequest = decoded.success.data.repository?.pullRequest;
1948 const behindBy = pullRequest?.baseRef?.compare?.behindBy;
1949 return Result.succeed({
1950 behindBy: typeof behindBy === "number" && behindBy >= 0 ? behindBy : null,
1951 viewerCanUpdate: pullRequest?.viewerCanUpdateBranch === true,
1952 });
1953}
1954
1354export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {1955export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
1355 repository(owner: $owner, name: $name) {1956 repository(owner: $owner, name: $name) {
1356 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {1957 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {
...@@ -1492,6 +2093,12 @@ export interface GitHubViewerAccess {...@@ -1492,6 +2093,12 @@ export interface GitHubViewerAccess {
1492 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */2093 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */
1493 readonly canUpdate: boolean;2094 readonly canUpdate: boolean;
1494 readonly didAuthor: boolean;2095 readonly didAuthor: boolean;
2096 /**
2097 * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is
2098 * false for a branch that is already current, so it answers "may update, and there is
2099 * something to update" at once. Absent where the comparison was not read.
2100 */
2101 readonly canUpdateBranch?: boolean;
1495}2102}
14962103
1497/**2104/**
...@@ -1539,6 +2146,8 @@ export interface GitHubPullRequestFilesPatch {...@@ -1539,6 +2146,8 @@ export interface GitHubPullRequestFilesPatch {
1539 readonly truncated: boolean;2146 readonly truncated: boolean;
1540 /** Files GitHub returned, counted before decoding, so the caller can page. */2147 /** Files GitHub returned, counted before decoding, so the caller can page. */
1541 readonly rawCount: number;2148 readonly rawCount: number;
2149 /** GitHub's own counts for the files whose hunks it withheld. */
2150 readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>;
1542}2151}
15432152
1544/**2153/**
...@@ -1554,6 +2163,7 @@ export function decodePullRequestFilesJson(...@@ -1554,6 +2163,7 @@ export function decodePullRequestFilesJson(
1554 return Result.fail(decoded.failure);2163 return Result.fail(decoded.failure);
1555 }2164 }
1556 const sections: string[] = [];2165 const sections: string[] = [];
2166 const omittedFileStats: PullRequestOmittedFileStat[] = [];
1557 let truncated = false;2167 let truncated = false;
1558 for (const entry of decoded.success) {2168 for (const entry of decoded.success) {
1559 const file = decodeFileEntry(entry);2169 const file = decodeFileEntry(entry);
...@@ -1565,7 +2175,12 @@ export function decodePullRequestFilesJson(...@@ -1565,7 +2175,12 @@ export function decodePullRequestFilesJson(
1565 // A file with no hunks is still a file that changed: a pure rename has none to give, and2175 // A file with no hunks is still a file that changed: a pure rename has none to give, and
1566 // a binary one has none that can be shown. Both are listed, and only the second is a hole2176 // a binary one has none that can be shown. Both are listed, and only the second is a hole
1567 // in the patch — leaving them out entirely would drop them from the change altogether.2177 // in the patch — leaving them out entirely would drop them from the change altogether.
1568 if ((value.additions ?? 0) + (value.deletions ?? 0) > 0) truncated = true;2178 const additions = value.additions ?? 0;
2179 const deletions = value.deletions ?? 0;
2180 if (additions + deletions > 0) {
2181 truncated = true;
2182 omittedFileStats.push({ path: value.filename, additions, deletions });
2183 }
1569 }2184 }
1570 // A rename counts its hunks against the old path, which is the only place it is named.2185 // A rename counts its hunks against the old path, which is the only place it is named.
1571 const oldPath =2186 const oldPath =
...@@ -1586,5 +2201,6 @@ export function decodePullRequestFilesJson(...@@ -1586,5 +2201,6 @@ export function decodePullRequestFilesJson(
1586 patch: sections.join(""),2201 patch: sections.join(""),
1587 truncated,2202 truncated,
1588 rawCount: decoded.success.length,2203 rawCount: decoded.success.length,
2204 omittedFileStats,
1589 });2205 });
1590}2206}
apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts+155
...@@ -2,12 +2,15 @@ import * as Result from "effect/Result";...@@ -2,12 +2,15 @@ import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 decodeAwardEmojiJson,
5 decodeCommitsJson,6 decodeCommitsJson,
6 decodeMergeRequestDetailJson,7 decodeMergeRequestDetailJson,
7 decodeMergeRequestDiffsJson,8 decodeMergeRequestDiffsJson,
8 decodeMergeRequestListJson,9 decodeMergeRequestListJson,
9 decodeNotesJson,10 decodeNotesJson,
11 decodeOwnAwardIdJson,
10 decodeViewerJson,12 decodeViewerJson,
13 gitLabAwardName,
11} from "./gitLabMergeRequestJson.ts";14} from "./gitLabMergeRequestJson.ts";
1215
13function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {16function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
...@@ -177,6 +180,31 @@ describe("decodeMergeRequestDetailJson", () => {...@@ -177,6 +180,31 @@ describe("decodeMergeRequestDetailJson", () => {
177 expect(detail.changedFiles).toBe(0);180 expect(detail.changedFiles).toBe(0);
178 });181 });
179182
183 it("reads either auto-merge field, and says nothing where GitLab named neither", () => {
184 const armed = (entry: Record<string, unknown>) =>
185 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled;
186
187 expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true);
188 // The newer name for the same fact, which older GitLab installs do not send.
189 expect(armed({ auto_merge_enabled: true })).toBe(true);
190 expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false);
191 // Absent is GitLab not saying, which the page must not read as "not armed".
192 expect(armed({})).toBeUndefined();
193 });
194
195 it("keeps a divergence GitLab did not count apart from a divergence of none", () => {
196 const behind = (entry: Record<string, unknown>) =>
197 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits;
198
199 expect(behind({ diverged_commits_count: 3 })).toBe(3);
200 // Counted and found level, which is the one answer that entitles the page to say so.
201 expect(behind({ diverged_commits_count: 0 })).toBe(0);
202 // An install that does not answer, and a null where the answer would have gone, are both
203 // silence: reading either as zero would tell a stale branch it is current.
204 expect(behind({})).toBeUndefined();
205 expect(behind({ diverged_commits_count: null })).toBeUndefined();
206 });
207
180 it("maps a pipeline waiting on a person to neutral, not failure", () => {208 it("maps a pipeline waiting on a person to neutral, not failure", () => {
181 const detail = expectSuccess(209 const detail = expectSuccess(
182 decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })),210 decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })),
...@@ -453,3 +481,130 @@ describe("merge request viewer fields", () => {...@@ -453,3 +481,130 @@ describe("merge request viewer fields", () => {
453 ).toBe(true);481 ).toBe(true);
454 });482 });
455});483});
484
485describe("decodeAwardEmojiJson", () => {
486 it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => {
487 const result = expectSuccess(
488 decodeAwardEmojiJson(
489 JSON.stringify({
490 data: {
491 currentUser: { username: "bilal" },
492 project: {
493 mergeRequest: {
494 awardEmoji: {
495 nodes: [
496 { name: "thumbsup", user: { username: "bilal" } },
497 { name: "thumbsup", user: { username: "julius" } },
498 ],
499 },
500 notes: {
501 pageInfo: { hasNextPage: false, endCursor: null },
502 nodes: [
503 {
504 id: "gid://gitlab/DiffNote/42",
505 awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] },
506 },
507 {
508 id: "gid://gitlab/Note/7",
509 // Not one of the eight the contract carries.
510 awardEmoji: {
511 nodes: [{ name: "partyparrot", user: { username: "bilal" } }],
512 },
513 },
514 ],
515 },
516 },
517 },
518 },
519 }),
520 ),
521 );
522
523 // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own
524 // username is left out of `actors` — the page names them "You" instead — while `count` still
525 // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on.
526 expect(result.reactions).toEqual([
527 { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true },
528 ]);
529 // Note 7's only award named nobody the eight recognise, so it carries no reactions and is
530 // left out of the map rather than kept empty.
531 expect([...result.reactionsByNoteId]).toEqual([
532 ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]],
533 ]);
534 expect(result.nextCursor).toBeNull();
535 });
536
537 it("hands back a cursor when GitLab has more notes to page", () => {
538 const result = expectSuccess(
539 decodeAwardEmojiJson(
540 JSON.stringify({
541 data: {
542 currentUser: null,
543 project: {
544 mergeRequest: {
545 awardEmoji: { nodes: [] },
546 notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] },
547 },
548 },
549 },
550 }),
551 ),
552 );
553
554 expect(result.nextCursor).toBe("Y3Vyc29yOjE");
555 });
556
557 it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => {
558 const result = expectSuccess(
559 decodeAwardEmojiJson(
560 JSON.stringify({
561 data: {
562 currentUser: { username: "Bilal" },
563 project: {
564 mergeRequest: {
565 awardEmoji: {
566 nodes: [
567 { name: "heart", user: { username: "bilal" } },
568 { name: "heart", user: { username: "julius" } },
569 ],
570 },
571 notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
572 },
573 },
574 },
575 }),
576 ),
577 );
578
579 expect(result.reactions).toEqual([
580 { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true },
581 ]);
582 });
583});
584
585describe("decodeOwnAwardIdJson", () => {
586 const awards = JSON.stringify([
587 { id: 101, name: "thumbsup", user: { username: "julius" } },
588 { id: 102, name: "thumbsup", user: { username: "bilal" } },
589 ]);
590
591 it("finds the reader's own award of that name, which is the one a removal deletes", () => {
592 expect(
593 expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })),
594 ).toBe(102);
595 });
596
597 it("returns nothing where the reader has no award of that name", () => {
598 expect(
599 expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })),
600 ).toBeNull();
601 });
602});
603
604describe("gitLabAwardName", () => {
605 it("spells the contents whose GitLab award name is not their own kebab-case", () => {
606 expect(gitLabAwardName("thumbs-up")).toBe("thumbsup");
607 expect(gitLabAwardName("laugh")).toBe("laughing");
608 expect(gitLabAwardName("hooray")).toBe("tada");
609 });
610});
apps/server/src/pullRequest/gitLabMergeRequestJson.ts+242
...@@ -11,6 +11,8 @@ import type {...@@ -11,6 +11,8 @@ import type {
11 PullRequestLabel,11 PullRequestLabel,
12 PullRequestMergeability,12 PullRequestMergeability,
13 PullRequestMergeCapabilities,13 PullRequestMergeCapabilities,
14 PullRequestReaction,
15 PullRequestReactionContent,
14 PullRequestReviewThread,16 PullRequestReviewThread,
15 PullRequestReviewerCandidate,17 PullRequestReviewerCandidate,
16 PullRequestState,18 PullRequestState,
...@@ -70,6 +72,21 @@ const RawMergeRequestSchema = Schema.Struct({...@@ -70,6 +72,21 @@ const RawMergeRequestSchema = Schema.Struct({
70 user: Schema.optional(72 user: Schema.optional(
71 Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),73 Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),
72 ),74 ),
75 /**
76 * Whether GitLab is holding this merge request to merge it once its pipeline goes green.
77 * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also
78 * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so
79 * either one saying yes is a yes.
80 */
81 merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),
82 auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
83 /**
84 * How far the target branch has moved on since this one left it, which is the same number
85 * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab
86 * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a
87 * single merge request — a list never carries it, however it is asked for.
88 */
89 diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),
73});90});
7491
75const RawNoteSchema = Schema.Struct({92const RawNoteSchema = Schema.Struct({
...@@ -209,6 +226,13 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {...@@ -209,6 +226,13 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {
209 readonly viewerCanMerge: boolean;226 readonly viewerCanMerge: boolean;
210 /** The reviewers as GitLab addresses them, which is what writing the set back takes. */227 /** The reviewers as GitLab addresses them, which is what writing the set back takes. */
211 readonly reviewerIds: ReadonlyArray<number>;228 readonly reviewerIds: ReadonlyArray<number>;
229 /** Absent where GitLab named neither auto-merge field, which is not the same as off. */
230 readonly autoMergeEnabled?: boolean;
231 /**
232 * Absent where GitLab did not count, which is not the same as a branch that has nothing behind
233 * it: an install too old to answer must not be read as saying the branch is current.
234 */
235 readonly divergedCommits?: number;
212}236}
213237
214function trimmed(value: string | null | undefined): string | null {238function trimmed(value: string | null | undefined): string | null {
...@@ -334,6 +358,10 @@ function toListItem(...@@ -334,6 +358,10 @@ function toListItem(
334358
335function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {359function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {
336 const listItem = toListItem(raw);360 const listItem = toListItem(raw);
361 const autoMerge =
362 raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null
363 ? undefined
364 : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true;
337 return {365 return {
338 ...listItem,366 ...listItem,
339 body: raw.description ?? "",367 body: raw.description ?? "",
...@@ -350,6 +378,8 @@ function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLab...@@ -350,6 +378,8 @@ function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLab
350 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>378 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>
351 reviewer.id === undefined ? [] : [reviewer.id],379 reviewer.id === undefined ? [] : [reviewer.id],
352 ),380 ),
381 ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),
382 ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),
353 };383 };
354}384}
355385
...@@ -696,3 +726,215 @@ export function decodeMergeRequestDiffsJson(...@@ -696,3 +726,215 @@ export function decodeMergeRequestDiffsJson(
696 rawCount: decoded.success.length,726 rawCount: decoded.success.length,
697 });727 });
698}728}
729
730/** GitLab's award names for the eight reactions the contract carries. */
731const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
732 "thumbs-up": "thumbsup",
733 "thumbs-down": "thumbsdown",
734 laugh: "laughing",
735 hooray: "tada",
736 confused: "confused",
737 heart: "heart",
738 rocket: "rocket",
739 eyes: "eyes",
740};
741
742const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> =
743 Object.fromEntries(
744 Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]),
745 ) as Readonly<Record<string, PullRequestReactionContent>>;
746
747export function gitLabAwardName(content: PullRequestReactionContent): string {
748 return GITLAB_AWARD_BY_CONTENT[content];
749}
750
751/**
752 * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the
753 * conversation comes from carries no award at all, and asking per note would be a request each.
754 *
755 * `currentUser` rides along because GitLab names who awarded but never says whether that is the
756 * reader — so the comparison is made here rather than paid for with a request of its own.
757 */
758export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) {
759 currentUser { username }
760 project(fullPath: $fullPath) {
761 mergeRequest(iid: $iid) {
762 awardEmoji { nodes { name user { username } } }
763 notes(first: 100, after: $cursor) {
764 pageInfo { hasNextPage endCursor }
765 nodes { id awardEmoji { nodes { name user { username } } } }
766 }
767 }
768 }
769}`;
770
771const RawAwardEmojiNodesSchema = Schema.optional(
772 Schema.NullOr(
773 Schema.Struct({
774 nodes: Schema.optional(
775 Schema.NullOr(
776 Schema.Array(
777 Schema.NullOr(
778 Schema.Struct({
779 name: Schema.optional(Schema.NullOr(Schema.String)),
780 user: Schema.optional(
781 Schema.NullOr(
782 Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }),
783 ),
784 ),
785 }),
786 ),
787 ),
788 ),
789 ),
790 }),
791 ),
792);
793
794const RawAwardEmojiPageSchema = Schema.Struct({
795 data: Schema.Struct({
796 currentUser: Schema.optional(
797 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
798 ),
799 project: Schema.NullOr(
800 Schema.Struct({
801 mergeRequest: Schema.NullOr(
802 Schema.Struct({
803 awardEmoji: RawAwardEmojiNodesSchema,
804 notes: Schema.optional(
805 Schema.NullOr(
806 Schema.Struct({
807 pageInfo: Schema.optional(
808 Schema.Struct({
809 hasNextPage: Schema.optional(Schema.Boolean),
810 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
811 }),
812 ),
813 nodes: Schema.Array(
814 Schema.NullOr(
815 Schema.Struct({
816 id: Schema.optional(Schema.NullOr(Schema.String)),
817 awardEmoji: RawAwardEmojiNodesSchema,
818 }),
819 ),
820 ),
821 }),
822 ),
823 ),
824 }),
825 ),
826 }),
827 ),
828 }),
829});
830
831const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema);
832
833/**
834 * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username
835 * is left out of `actors` — the page names them "You" instead, and leaving it in would name them
836 * twice — but `count` still counts them along with everyone else.
837 */
838function toReactions(
839 nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>,
840 viewer: string | null,
841): ReadonlyArray<PullRequestReaction> {
842 const normalizedViewer = viewer?.toLowerCase() ?? null;
843 const groups = new Map<
844 PullRequestReactionContent,
845 { count: number; actors: string[]; viewer: boolean }
846 >();
847 for (const node of nodes?.nodes ?? []) {
848 // An award outside the eight is left out rather than shown under a name the picker has no
849 // way to take back: GitLab accepts any emoji, and the other hosts accept none of them.
850 const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""];
851 if (content === undefined) continue;
852 const username = trimmed(node?.user?.username);
853 if (username === null) continue;
854 const group = groups.get(content) ?? { count: 0, actors: [], viewer: false };
855 group.count++;
856 if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) {
857 group.viewer = true;
858 } else {
859 group.actors.push(username);
860 }
861 groups.set(content, group);
862 }
863 return [...groups].flatMap(([content, group]) =>
864 group.count === 0
865 ? []
866 : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }],
867 );
868}
869
870/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */
871function noteIdOf(gid: string | null | undefined): string | null {
872 const id = trimmed(gid)?.split("/").at(-1);
873 return id !== undefined && /^\d+$/.test(id) ? id : null;
874}
875
876export interface GitLabAwardEmojiPage {
877 /** The merge request's own awards, which are the ones on its description. */
878 readonly reactions: ReadonlyArray<PullRequestReaction>;
879 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
880 readonly nextCursor: string | null;
881}
882
883export function decodeAwardEmojiJson(
884 raw: string,
885): Result.Result<GitLabAwardEmojiPage, DecodeFailure> {
886 const decoded = decodeAwardEmojiPage(raw);
887 if (!Result.isSuccess(decoded)) {
888 return Result.fail(decoded.failure);
889 }
890 const data = decoded.success.data;
891 const viewer = trimmed(data.currentUser?.username);
892 const mergeRequest = data.project?.mergeRequest;
893 const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>();
894 for (const node of mergeRequest?.notes?.nodes ?? []) {
895 const id = noteIdOf(node?.id);
896 if (id === null) continue;
897 const reactions = toReactions(node?.awardEmoji, viewer);
898 if (reactions.length > 0) reactionsByNoteId.set(id, reactions);
899 }
900 const pageInfo = mergeRequest?.notes?.pageInfo;
901 return Result.succeed({
902 reactions: toReactions(mergeRequest?.awardEmoji, viewer),
903 reactionsByNoteId,
904 nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null,
905 });
906}
907
908const RawAwardSchema = Schema.Struct({
909 id: Schema.Int,
910 name: Schema.optional(Schema.NullOr(Schema.String)),
911 user: Schema.optional(
912 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
913 ),
914});
915
916const decodeAward = Schema.decodeUnknownExit(RawAwardSchema);
917
918/**
919 * The reader's own award of one name on a subject, which is what taking a reaction back is
920 * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji.
921 */
922export function decodeOwnAwardIdJson(
923 raw: string,
924 input: { readonly content: PullRequestReactionContent; readonly viewer: string },
925): Result.Result<number | null, DecodeFailure> {
926 const decoded = decodeUnknownList(raw);
927 if (!Result.isSuccess(decoded)) {
928 return Result.fail(decoded.failure);
929 }
930 const name = gitLabAwardName(input.content);
931 for (const entry of decoded.success) {
932 const award = decodeAward(entry);
933 if (Exit.isFailure(award)) continue;
934 const value = award.value;
935 if (trimmed(value.name)?.toLowerCase() !== name) continue;
936 if (trimmed(value.user?.username) !== input.viewer) continue;
937 return Result.succeed(value.id);
938 }
939 return Result.succeed(null);
940}
apps/server/src/pullRequest/pullRequestChecks.test.ts created+93
...@@ -0,0 +1,93 @@
1import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { dedupeChecks } from "./pullRequestChecks.ts";
5
6function entry(
7 name: string,
8 status: PullRequestCheckStatus,
9 extra: { readonly workflowName?: string | null; readonly at?: string | null } = {},
10): {
11 readonly check: PullRequestCheck;
12 readonly workflowName: string | null;
13 readonly at: string | null;
14} {
15 return {
16 check: { name, status, description: null, url: null },
17 workflowName: extra.workflowName ?? null,
18 at: extra.at ?? null,
19 };
20}
21
22describe("dedupeChecks", () => {
23 it("keeps the newest run of a check the host listed twice", () => {
24 const checks = dedupeChecks([
25 entry("Prepare PR size config", "success", {
26 workflowName: "PR Size",
27 at: "2026-08-11T16:06:25Z",
28 }),
29 entry("Prepare PR size config", "pending", {
30 workflowName: "PR Size",
31 at: "2026-08-11T17:01:04Z",
32 }),
33 ]);
34
35 expect(checks.map((check) => [check.name, check.status])).toEqual([
36 ["Prepare PR size config", "pending"],
37 ]);
38 });
39
40 it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => {
41 const checks = dedupeChecks([
42 entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
43 entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
44 entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }),
45 ]);
46
47 expect(checks.map((check) => [check.name, check.status])).toEqual([
48 ["lint", "failure"],
49 ["test", "success"],
50 ]);
51 });
52
53 it("loses a run that never said when it happened to one that did, whichever came first", () => {
54 const undated = dedupeChecks([
55 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
56 entry("build", "pending"),
57 ]);
58 const dated = dedupeChecks([
59 entry("build", "pending"),
60 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
61 ]);
62
63 expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]);
64 });
65
66 it("takes the last copy when neither run is dated, which is how an update is listed", () => {
67 const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]);
68
69 expect(checks.map((check) => check.status)).toEqual(["failure"]);
70 });
71
72 it("keeps two workflows that name a job the same thing, and says which is which", () => {
73 const checks = dedupeChecks([
74 entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
75 entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }),
76 ]);
77
78 expect(checks.map((check) => [check.name, check.status])).toEqual([
79 ["CI / build", "success"],
80 ["Release / build", "failure"],
81 ]);
82 });
83
84 it("leaves a colliding check with no workflow of its own unqualified", () => {
85 // An app-provided check run belongs to no workflow, which GitHub reports as an empty name.
86 const checks = dedupeChecks([
87 entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }),
88 entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
89 ]);
90
91 expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]);
92 });
93});
apps/server/src/pullRequest/pullRequestChecks.ts created+55
...@@ -0,0 +1,55 @@
1import type { PullRequestCheck } from "@t3tools/contracts";
2
3/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */
4function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean {
5 if (candidate === null) return kept === null;
6 return kept === null || candidate >= kept;
7}
8
9/**
10 * One row per check rather than one per run of it.
11 *
12 * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or
13 * while a second run of it is already live — the same check arrives twice, and both copies reach
14 * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what
15 * identifies it, qualified by the workflow it belongs to since two workflows are free to name a
16 * job the same thing.
17 *
18 * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the
19 * one before it, and a run that never said when it happened loses to one that did. A tie goes to
20 * whichever came last, because a host lists a re-run after the run it repeats.
21 *
22 * The order is the host's own, held at the place each check first appeared, so a re-run landing
23 * mid-read replaces a row where it stands instead of reshuffling the list under the reader.
24 *
25 * Two checks that survive under the same name are then genuinely different ones, since they came
26 * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself.
27 * A survivor whose workflow the host did not name keeps its bare name rather than being qualified
28 * with nothing.
29 */
30export function dedupeChecks(
31 entries: ReadonlyArray<{
32 readonly check: PullRequestCheck;
33 readonly workflowName: string | null;
34 readonly at: string | null;
35 }>,
36): ReadonlyArray<PullRequestCheck> {
37 const newestByCheck = new Map<string, (typeof entries)[number]>();
38 for (const entry of entries) {
39 const key = `${entry.workflowName ?? ""} ${entry.check.name}`;
40 const kept = newestByCheck.get(key);
41 // Re-setting a key a Map already holds keeps its first position, which is the order wanted.
42 if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry);
43 }
44 const survivors = [...newestByCheck.values()];
45 const countsByName = new Map<string, number>();
46 for (const entry of survivors) {
47 countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1);
48 }
49 return survivors.map((entry) => {
50 const workflowName = entry.workflowName ?? "";
51 return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1
52 ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` }
53 : entry.check;
54 });
55}
apps/server/src/sourceControl/GitHubCli.test.ts+30
...@@ -373,4 +373,34 @@ describe("GitHubCli.layer", () => {...@@ -373,4 +373,34 @@ describe("GitHubCli.layer", () => {
373 assert.equal(error.message.includes(cause.detail), false);373 assert.equal(error.message.includes(cause.detail), false);
374 }).pipe(Effect.provide(layer)),374 }).pipe(Effect.provide(layer)),
375 );375 );
376
377 it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () =>
378 Effect.gen(function* () {
379 const cause = new VcsProcessExitError({
380 operation: "GitHubCli.execute",
381 command: "gh",
382 cwd: "/repo",
383 exitCode: 1,
384 failureKind: "rate-limited",
385 detail: "API rate limit exceeded.",
386 stderrLength: 82,
387 stderrTruncated: false,
388 });
389 mockRun.mockReturnValueOnce(Effect.fail(cause));
390
391 const gh = yield* GitHubCli.GitHubCli;
392 const error = yield* gh
393 .listOpenPullRequests({
394 cwd: "/repo",
395 headSelector: "feature/rate-limited",
396 })
397 .pipe(Effect.flip);
398
399 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
400 assert.include(error.detail, "GitHub API rate limit exceeded");
401 assert.include(error.detail, "gh api rate_limit");
402 assert.strictEqual(error.cause, cause);
403 assert.notInclude(error.message, "user ID");
404 }).pipe(Effect.provide(layer)),
405 );
376});406});
apps/server/src/sourceControl/GitHubCli.ts+17
...@@ -51,6 +51,19 @@ export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass<GitHub...@@ -51,6 +51,19 @@ export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass<GitHub
51 }51 }
52}52}
5353
54export class GitHubCliRateLimitError extends Schema.TaggedErrorClass<GitHubCliRateLimitError>()(
55 "GitHubCliRateLimitError",
56 gitHubCliFailureFields,
57) {
58 get detail(): string {
59 return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time.";
60 }
61
62 override get message(): string {
63 return `GitHub CLI failed in execute: ${this.detail}`;
64 }
65}
66
54export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass<GitHubPullRequestNotFoundError>()(67export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass<GitHubPullRequestNotFoundError>()(
55 "GitHubPullRequestNotFoundError",68 "GitHubPullRequestNotFoundError",
56 gitHubCliFailureFields,69 gitHubCliFailureFields,
...@@ -138,6 +151,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass<GitHubR...@@ -138,6 +151,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass<GitHubR
138export const GitHubCliError = Schema.Union([151export const GitHubCliError = Schema.Union([
139 GitHubCliUnavailableError,152 GitHubCliUnavailableError,
140 GitHubCliAuthenticationError,153 GitHubCliAuthenticationError,
154 GitHubCliRateLimitError,
141 GitHubPullRequestNotFoundError,155 GitHubPullRequestNotFoundError,
142 GitHubCliCommandError,156 GitHubCliCommandError,
143 GitHubPullRequestListDecodeError,157 GitHubPullRequestListDecodeError,
...@@ -170,6 +184,9 @@ export function fromVcsError(...@@ -170,6 +184,9 @@ export function fromVcsError(
170 if (error.failureKind === "authentication") {184 if (error.failureKind === "authentication") {
171 return new GitHubCliAuthenticationError({ ...context, cause: error });185 return new GitHubCliAuthenticationError({ ...context, cause: error });
172 }186 }
187 if (error.failureKind === "rate-limited") {
188 return new GitHubCliRateLimitError({ ...context, cause: error });
189 }
173 if (error.failureKind === "not-found") {190 if (error.failureKind === "not-found") {
174 return new GitHubPullRequestNotFoundError({ ...context, cause: error });191 return new GitHubPullRequestNotFoundError({ ...context, cause: error });
175 }192 }
apps/server/src/sourceControl/GitLabCli.ts+1
...@@ -127,6 +127,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliComm...@@ -127,6 +127,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliComm
127 case "authentication":127 case "authentication":
128 return new GitLabCliAuthenticationError({ ...context, cause });128 return new GitLabCliAuthenticationError({ ...context, cause });
129 case "not-found":129 case "not-found":
130 case "rate-limited":
130 case "command-failed":131 case "command-failed":
131 case undefined:132 case undefined:
132 return new GitLabCliCommandError({ ...context, cause });133 return new GitLabCliCommandError({ ...context, cause });
apps/server/src/vcs/VcsProcess.test.ts+25
...@@ -140,6 +140,31 @@ describe("VcsProcess.run", () => {...@@ -140,6 +140,31 @@ describe("VcsProcess.run", () => {
140 }).pipe(provideLive),140 }).pipe(provideLive),
141 );141 );
142142
143 it.effect("classifies API rate limits without retaining provider stderr", () =>
144 Effect.gen(function* () {
145 const providerStderr =
146 "GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value.";
147 const error = yield* run({
148 operation: "test.rate-limit",
149 command: "node",
150 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
151 cwd: process.cwd(),
152 }).pipe(Effect.flip);
153
154 expect(error).toBeInstanceOf(VcsProcessExitError);
155 expect(error).toMatchObject({
156 command: "node",
157 exitCode: 1,
158 detail: "API rate limit exceeded.",
159 failureKind: "rate-limited",
160 stderrLength: providerStderr.length,
161 stderrTruncated: false,
162 });
163 expect(error.message).not.toContain(providerStderr);
164 expect(error.message).not.toContain("secret-value");
165 }).pipe(provideLive),
166 );
167
143 it.effect("retains spawn causes without exposing process arguments in the error message", () =>168 it.effect("retains spawn causes without exposing process arguments in the error message", () =>
144 Effect.gen(function* () {169 Effect.gen(function* () {
145 const secretArgument = "--token=super-secret-token";170 const secretArgument = "--token=super-secret-token";
apps/server/src/vcs/VcsProcess.ts+8
...@@ -69,6 +69,14 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai...@@ -69,6 +69,14 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
69 return "authentication";69 return "authentication";
70 }70 }
7171
72 if (
73 normalized.includes("api rate limit") ||
74 normalized.includes("rate limit exceeded") ||
75 normalized.includes("secondary rate limit")
76 ) {
77 return "rate-limited";
78 }
79
72 if (80 if (
73 (command === "gh" &&81 (command === "gh" &&
74 (normalized.includes("could not resolve to a pullrequest") ||82 (normalized.includes("could not resolve to a pullrequest") ||
apps/server/src/ws.ts+16
...@@ -1662,10 +1662,22 @@ const makeWsRpcLayer = (...@@ -1662,10 +1662,22 @@ const makeWsRpcLayer = (
1662 observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), {1662 observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), {
1663 "rpc.aggregate": "pull-requests",1663 "rpc.aggregate": "pull-requests",
1664 }),1664 }),
1665 [WS_METHODS.pullRequestsUpdate]: (input) =>
1666 observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), {
1667 "rpc.aggregate": "pull-requests",
1668 }),
1665 [WS_METHODS.pullRequestsComment]: (input) =>1669 [WS_METHODS.pullRequestsComment]: (input) =>
1666 observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), {1670 observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), {
1667 "rpc.aggregate": "pull-requests",1671 "rpc.aggregate": "pull-requests",
1668 }),1672 }),
1673 [WS_METHODS.pullRequestsUpdateComment]: (input) =>
1674 observeRpcEffect(
1675 WS_METHODS.pullRequestsUpdateComment,
1676 pullRequests.updateComment(input),
1677 {
1678 "rpc.aggregate": "pull-requests",
1679 },
1680 ),
1669 [WS_METHODS.pullRequestsSubmitReview]: (input) =>1681 [WS_METHODS.pullRequestsSubmitReview]: (input) =>
1670 observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), {1682 observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), {
1671 "rpc.aggregate": "pull-requests",1683 "rpc.aggregate": "pull-requests",
...@@ -1682,6 +1694,10 @@ const makeWsRpcLayer = (...@@ -1682,6 +1694,10 @@ const makeWsRpcLayer = (
1682 pullRequests.setThreadResolution(input),1694 pullRequests.setThreadResolution(input),
1683 { "rpc.aggregate": "pull-requests" },1695 { "rpc.aggregate": "pull-requests" },
1684 ),1696 ),
1697 [WS_METHODS.pullRequestsSetReaction]: (input) =>
1698 observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), {
1699 "rpc.aggregate": "pull-requests",
1700 }),
1685 [WS_METHODS.pullRequestsInvalidate]: (input) =>1701 [WS_METHODS.pullRequestsInvalidate]: (input) =>
1686 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {1702 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {
1687 "rpc.aggregate": "pull-requests",1703 "rpc.aggregate": "pull-requests",
apps/web/src/components/ChatMarkdown.tsx+2-1
...@@ -1569,13 +1569,14 @@ function ChatMarkdown({...@@ -1569,13 +1569,14 @@ function ChatMarkdown({
1569 if (href) openChangeRequestLink(event, href);1569 if (href) openChangeRequestLink(event, href);
1570 }}1570 }}
1571 onContextMenu={(event) => {1571 onContextMenu={(event) => {
1572 if (!canOpenInPreview || !href || !faviconHost) return;1572 if (!href || !faviconHost) return;
1573 event.preventDefault();1573 event.preventDefault();
1574 event.stopPropagation();1574 event.stopPropagation();
1575 const api = readLocalApi();1575 const api = readLocalApi();
1576 if (!api) return;1576 if (!api) return;
1577 void showExternalLinkContextMenu({1577 void showExternalLinkContextMenu({
1578 href,1578 href,
1579 canOpenInPreview,
1579 position: { x: event.clientX, y: event.clientY },1580 position: { x: event.clientX, y: event.clientY },
1580 showContextMenu: (items, position) => api.contextMenu.show(items, position),1581 showContextMenu: (items, position) => api.contextMenu.show(items, position),
1581 openInPreview: async (target) => {1582 openInPreview: async (target) => {
apps/web/src/components/ChatView.tsx+28-11
...@@ -123,11 +123,11 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";...@@ -123,11 +123,11 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
123import { useMediaQuery } from "../hooks/useMediaQuery";123import { useMediaQuery } from "../hooks/useMediaQuery";
124import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";124import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
125import {125import {
126 pullRequestSurfaceId,
127 selectActiveRightPanel,126 selectActiveRightPanel,
128 selectActiveRightPanelSurface,127 selectActiveRightPanelSurface,
129 selectThreadRightPanelState,128 selectThreadRightPanelState,
130 type RightPanelSurface,129 type RightPanelSurface,
130 updatePullRequestTabStatus,
131 useRightPanelStore,131 useRightPanelStore,
132} from "../rightPanelStore";132} from "../rightPanelStore";
133import {133import {
...@@ -144,6 +144,7 @@ import {...@@ -144,6 +144,7 @@ import {
144 selectThreadPreviewMiniPlayer,144 selectThreadPreviewMiniPlayer,
145 usePreviewMiniPlayerStore,145 usePreviewMiniPlayerStore,
146} from "../previewMiniPlayerStore";146} from "../previewMiniPlayerStore";
147import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";
147import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";148import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
148import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";149import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
149import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";150import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
...@@ -1600,14 +1601,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1600,14 +1601,18 @@ function ChatViewContent(props: ChatViewProps) {
1600 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<1601 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
1601 Record<string, PullRequestTabStatus>1602 Record<string, PullRequestTabStatus>
1602 >({});1603 >({});
1603 const handlePullRequestTabStatusChange = useCallback((status: PullRequestTabStatus) => {1604 // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so
1604 const id = pullRequestSurfaceId(status);1605 // the tab is found again whether or not that surface was opened with an environment on it.
1605 setPullRequestTabStatuses((current) =>1606 const activePullRequestSurfaceId =
1606 current[id]?.state === status.state && current[id]?.isDraft === status.isDraft1607 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1607 ? current1608 const handlePullRequestTabStatusChange = useCallback(
1608 : { ...current, [id]: status },1609 (status: PullRequestTabStatus) => {
1609 );1610 const id = activePullRequestSurfaceId;
1610 }, []);1611 if (id === undefined) return;
1612 setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status));
1613 },
1614 [activePullRequestSurfaceId],
1615 );
1611 const activeFileSurface =1616 const activeFileSurface =
1612 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;1617 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1613 const activePreviewState = useThreadPreviewState(activeThreadRef);1618 const activePreviewState = useThreadPreviewState(activeThreadRef);
...@@ -6082,11 +6087,23 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6082,11 +6087,23 @@ function ChatViewContent(props: ChatViewProps) {
6082 number: activeRightPanelSurface.number,6087 number: activeRightPanelSurface.number,
6083 }}6088 }}
6084 context={6089 context={
6085 activeThreadPr?.number === activeRightPanelSurface.number &&6090 isThreadOwnPullRequest(
6086 threadRepository === activeRightPanelSurface.repository6091 {
6092 projectId: activeProject?.id ?? null,
6093 repository: threadRepository,
6094 number: activeThreadPr?.number ?? null,
6095 },
6096 {
6097 projectId: activeRightPanelSurface.projectId,
6098 repository: activeRightPanelSurface.repository,
6099 number: activeRightPanelSurface.number,
6100 },
6101 )
6087 ? "thread"6102 ? "thread"
6088 : "page"6103 : "page"
6089 }6104 }
6105 chromeVariant="collapse"
6106 composerDraftTarget={composerDraftTarget}
6090 onStateChange={handlePullRequestTabStatusChange}6107 onStateChange={handlePullRequestTabStatusChange}
6091 />6108 />
6092 ) : activeRightPanelSurface?.kind === "agents" ? (6109 ) : activeRightPanelSurface?.kind === "agents" ? (
apps/web/src/components/chat/externalLinkContextMenu.test.ts+19
...@@ -41,6 +41,25 @@ describe("external chat link context menu", () => {...@@ -41,6 +41,25 @@ describe("external chat link context menu", () => {
41 expect(harness.copyLink).not.toHaveBeenCalled();41 expect(harness.copyLink).not.toHaveBeenCalled();
42 });42 });
4343
44 it("still offers the link's own actions where the integrated browser cannot be opened", async () => {
45 const harness = createHarness(null);
46
47 await showExternalLinkContextMenu({
48 href: "https://github.com/pingdotgg/t3code/pull/6169",
49 canOpenInPreview: false,
50 position: { x: 4, y: 8 },
51 ...harness,
52 });
53
54 expect(harness.showContextMenu).toHaveBeenCalledWith(
55 [
56 { id: "open-external", label: "Open in system browser" },
57 { id: "copy-link", label: "Copy Link" },
58 ],
59 { x: 4, y: 8 },
60 );
61 });
62
44 it("copies the exact destination without opening it", async () => {63 it("copies the exact destination without opening it", async () => {
45 const harness = createHarness("copy-link");64 const harness = createHarness("copy-link");
46 const href = "https://example.com/docs?topic=menus#copy";65 const href = "https://example.com/docs?topic=menus#copy";
apps/web/src/components/chat/externalLinkContextMenu.ts+18-1
...@@ -20,9 +20,25 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [...@@ -20,9 +20,25 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
20 { id: "copy-link", label: "Copy Link" },20 { id: "copy-link", label: "Copy Link" },
21] as const satisfies readonly ContextMenuItem<ExternalLinkContextMenuAction>[];21] as const satisfies readonly ContextMenuItem<ExternalLinkContextMenuAction>[];
2222
23/**
24 * The integrated browser is not always there to offer — it needs a thread to open beside and a
25 * runtime that can show it — but the other two answers hold wherever a link does. Dropping the
26 * whole menu with the one item that cannot be honoured is what left a right-click on a link
27 * showing the platform's cut-and-paste menu instead of a way to copy the link.
28 */
29export function externalLinkContextMenuItems(options: {
30 readonly canOpenInPreview: boolean;
31}): readonly ContextMenuItem<ExternalLinkContextMenuAction>[] {
32 return options.canOpenInPreview
33 ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS
34 : EXTERNAL_LINK_CONTEXT_MENU_ITEMS.filter((item) => item.id !== "open-in-preview");
35}
36
23interface ShowExternalLinkContextMenuOptions {37interface ShowExternalLinkContextMenuOptions {
24 readonly href: string;38 readonly href: string;
25 readonly position: { readonly x: number; readonly y: number };39 readonly position: { readonly x: number; readonly y: number };
40 /** Absent means yes, which is what every caller before the browser could be missing meant. */
41 readonly canOpenInPreview?: boolean;
26 readonly showContextMenu: (42 readonly showContextMenu: (
27 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],43 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],
28 position: { readonly x: number; readonly y: number },44 position: { readonly x: number; readonly y: number },
...@@ -50,6 +66,7 @@ export function resolveExternalWebLinkHost(href: string | undefined): string | n...@@ -50,6 +66,7 @@ export function resolveExternalWebLinkHost(href: string | undefined): string | n
50export async function showExternalLinkContextMenu({66export async function showExternalLinkContextMenu({
51 href,67 href,
52 position,68 position,
69 canOpenInPreview = true,
53 showContextMenu,70 showContextMenu,
54 openInPreview,71 openInPreview,
55 openExternal,72 openExternal,
...@@ -58,7 +75,7 @@ export async function showExternalLinkContextMenu({...@@ -58,7 +75,7 @@ export async function showExternalLinkContextMenu({
58}: ShowExternalLinkContextMenuOptions): Promise<void> {75}: ShowExternalLinkContextMenuOptions): Promise<void> {
59 let action: ExternalLinkContextMenuAction | null;76 let action: ExternalLinkContextMenuAction | null;
60 try {77 try {
61 action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position);78 action = await showContextMenu(externalLinkContextMenuItems({ canOpenInPreview }), position);
62 } catch (cause) {79 } catch (cause) {
63 reportFailure("show-link-context-menu", cause);80 reportFailure("show-link-context-menu", cause);
64 return;81 return;
apps/web/src/components/diffs/StyledDiffCodeView.tsx+9-1
...@@ -269,6 +269,11 @@ type StyledDiffCodeViewProps<LAnnotation> = (...@@ -269,6 +269,11 @@ type StyledDiffCodeViewProps<LAnnotation> = (
269) & {269) & {
270 readonly options?: StyledDiffCodeViewOptions<LAnnotation>;270 readonly options?: StyledDiffCodeViewOptions<LAnnotation>;
271 readonly viewerRef?: Ref<CodeViewHandle<LAnnotation>>;271 readonly viewerRef?: Ref<CodeViewHandle<LAnnotation>>;
272 /**
273 * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has
274 * to restyle chrome the viewer owns — such as replacing its per-file line counts.
275 */
276 readonly unsafeCSSExtra?: string;
272};277};
273278
274/** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */279/** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */
...@@ -276,6 +281,7 @@ export function StyledDiffCodeView<LAnnotation = undefined>({...@@ -276,6 +281,7 @@ export function StyledDiffCodeView<LAnnotation = undefined>({
276 options,281 options,
277 viewerRef,282 viewerRef,
278 className,283 className,
284 unsafeCSSExtra,
279 ...props285 ...props
280}: StyledDiffCodeViewProps<LAnnotation>) {286}: StyledDiffCodeViewProps<LAnnotation>) {
281 return (287 return (
...@@ -291,7 +297,9 @@ export function StyledDiffCodeView<LAnnotation = undefined>({...@@ -291,7 +297,9 @@ export function StyledDiffCodeView<LAnnotation = undefined>({
291 }297 }
292 options={{298 options={{
293 ...options,299 ...options,
294 unsafeCSS: DIFF_VIEW_UNSAFE_CSS,300 unsafeCSS: unsafeCSSExtra
301 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
302 : DIFF_VIEW_UNSAFE_CSS,
295 itemMetrics: {303 itemMetrics: {
296 diffHeaderHeight: 32,304 diffHeaderHeight: 32,
297 hunkSeparatorHeight: 24,305 hunkSeparatorHeight: 24,
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx created+134
...@@ -0,0 +1,134 @@
1import type {
2 EnvironmentId,
3 PullRequestCheck,
4 PullRequestChecksState,
5 PullRequestRef,
6} from "@t3tools/contracts";
7
8import { readLocalApi } from "~/localApi";
9import { cn } from "~/lib/utils";
10import { pullRequestEnvironment } from "~/state/pullRequests";
11import { useEnvironmentQuery } from "~/state/query";
12
13import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
14import {
15 PullRequestCheckStatusIcon,
16 pullRequestCheckStatusLabel,
17 pullRequestChecksStatePresentation,
18 summarizePullRequestChecks,
19} from "./pullRequestPresentation";
20
21/**
22 * The checks behind the rollup, for a row that only carries the rollup. Mounted by the popup, so
23 * the read starts when somebody opens it rather than once per row of a listing — the detail read
24 * is a request per pull request, and a page of them at rest would be a hundred.
25 */
26function LazyChecksBody({
27 environmentId,
28 reference,
29}: {
30 environmentId: EnvironmentId;
31 reference: PullRequestRef;
32}) {
33 const detailQuery = useEnvironmentQuery(
34 pullRequestEnvironment.detail({ environmentId, input: reference }),
35 );
36 if (detailQuery.error !== null) {
37 return <p className="text-muted-foreground text-xs">{detailQuery.error}</p>;
38 }
39 if (detailQuery.data === null) {
40 return (
41 <p className="text-muted-foreground text-xs">
42 {detailQuery.isPending ? "Loading checks…" : "No checks reported"}
43 </p>
44 );
45 }
46 return <ChecksBody checks={detailQuery.data.checks} />;
47}
48
49function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {
50 if (checks.length === 0) {
51 return <p className="text-muted-foreground text-xs">No checks reported</p>;
52 }
53 return (
54 <ul className="flex flex-col gap-1">
55 {/* Keyed by position as well as by name: the host is the one that decides how many runs
56 share a name, and a repeated key is a rendering fault rather than a wrong list. */}
57 {checks.map((check, index) => (
58 <li key={`${index}:${check.name}`} className="flex items-center gap-2 text-xs">
59 <PullRequestCheckStatusIcon status={check.status} />
60 <span className="min-w-0 flex-1 truncate" title={check.description ?? check.name}>
61 {check.name}
62 </span>
63 <span className="shrink-0 text-muted-foreground">
64 {pullRequestCheckStatusLabel(check.status)}
65 </span>
66 {check.url === null ? null : (
67 <button
68 type="button"
69 className="shrink-0 text-primary hover:underline"
70 onClick={() => void readLocalApi()?.shell.openExternal(check.url ?? "")}
71 >
72 Details
73 </button>
74 )}
75 </li>
76 ))}
77 </ul>
78 );
79}
80
81/**
82 * The checks indicator and what it opens, in both places a change request is shown: a listing
83 * row, which knows only the rollup, and the detail header, which is already holding every check.
84 *
85 * `checks` decides between the two. Given them, nothing is read; without them, the popup reads
86 * the detail itself, which is why the row must also say which environment it came from.
87 */
88export function PullRequestChecksPopover({
89 checksState,
90 checks,
91 environmentId,
92 reference,
93 className,
94}: {
95 checksState: PullRequestChecksState;
96 /** The checks already in hand, for the detail header. Absent on a listing row. */
97 checks?: ReadonlyArray<PullRequestCheck>;
98 environmentId?: EnvironmentId;
99 reference?: PullRequestRef;
100 className?: string;
101}) {
102 const presentation = pullRequestChecksStatePresentation(checksState);
103 // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads.
104 const summary = checks === undefined ? null : summarizePullRequestChecks(checks);
105 return (
106 <Popover>
107 {/* A listing row is itself a button, so the trigger renders as a span: a nested button is
108 not valid inside one. The click is stopped here so opening the checks does not also
109 select the row it sits on. */}
110 <PopoverTrigger
111 render={
112 <span
113 role="button"
114 tabIndex={0}
115 aria-label={`Checks: ${presentation.label}`}
116 className={cn("inline-flex shrink-0 cursor-pointer items-center", className)}
117 />
118 }
119 onClick={(event) => event.stopPropagation()}
120 >
121 <presentation.Icon aria-hidden className={cn("size-3.5", presentation.toneClassName)} />
122 </PopoverTrigger>
123 <PopoverPopup align="start" className="w-80 max-w-full" side="bottom">
124 <p className="mb-2 font-medium text-sm">{presentation.label}</p>
125 {summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}
126 {checks !== undefined ? (
127 <ChecksBody checks={checks} />
128 ) : environmentId !== undefined && reference !== undefined ? (
129 <LazyChecksBody environmentId={environmentId} reference={reference} />
130 ) : null}
131 </PopoverPopup>
132 </Popover>
133 );
134}
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+102-11
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 EnvironmentId,4 EnvironmentId,
5 PullRequestDetailView,5 PullRequestDetailView,
6 PullRequestDiffSide,6 PullRequestDiffSide,
7 PullRequestOmittedFileStat,
7 PullRequestRef,8 PullRequestRef,
8 PullRequestReviewThread,9 PullRequestReviewThread,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
...@@ -28,6 +29,8 @@ import { useClientSettings } from "~/hooks/useSettings";...@@ -28,6 +29,8 @@ import { useClientSettings } from "~/hooks/useSettings";
28import { useTheme } from "~/hooks/useTheme";29import { useTheme } from "~/hooks/useTheme";
29import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";30import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";
30import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic";31import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic";
32import { canEditPullRequestComment } from "./pullRequestEditing.logic";
33import { orderDiffFiles } from "./pullRequestFileOrder.logic";
31import {34import {
32 buildFileDiffRenderKey,35 buildFileDiffRenderKey,
33 fnv1a32,36 fnv1a32,
...@@ -95,8 +98,20 @@ interface DiffSlice {...@@ -95,8 +98,20 @@ interface DiffSlice {
95 readonly patch: string;98 readonly patch: string;
96 readonly truncated: boolean;99 readonly truncated: boolean;
97 readonly nextCursor: string | null;100 readonly nextCursor: string | null;
101 readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>;
98}102}
99103
104/**
105 * The viewer's own per-file counts are hidden and drawn from this side of its shadow root
106 * instead: its counts are hunk sums, and a file whose hunks the host withheld would read as
107 * an empty change rather than as the counts the host did report.
108 */
109const REPLACE_FILE_COUNTS_CSS = `
110[data-diffs-header] [data-additions-count],
111[data-diffs-header] [data-deletions-count] {
112 display: none !important;
113}`;
114
100/** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */115/** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */
101const NO_SLICES: ReadonlyArray<DiffSlice> = [];116const NO_SLICES: ReadonlyArray<DiffSlice> = [];
102117
...@@ -155,6 +170,7 @@ export function PullRequestCodeTab({...@@ -155,6 +170,7 @@ export function PullRequestCodeTab({
155 selectedCommitOid,170 selectedCommitOid,
156 onSelectedCommitChange,171 onSelectedCommitChange,
157 pendingFinding,172 pendingFinding,
173 fixFindingLabel = "Fix in a thread",
158 onFixFinding,174 onFixFinding,
159 onAskAboutSelection,175 onAskAboutSelection,
160 onRefresh,176 onRefresh,
...@@ -168,6 +184,7 @@ export function PullRequestCodeTab({...@@ -168,6 +184,7 @@ export function PullRequestCodeTab({
168 onSelectedCommitChange: (oid: string | null) => void;184 onSelectedCommitChange: (oid: string | null) => void;
169 /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */185 /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */
170 pendingFinding?: string | null;186 pendingFinding?: string | null;
187 fixFindingLabel?: string;
171 onFixFinding?: (finding: PullRequestFinding) => void;188 onFixFinding?: (finding: PullRequestFinding) => void;
172 /** Absent where a selection has no agent to go to, which takes the Ask button off the box. */189 /** Absent where a selection has no agent to go to, which takes the Ask button off the box. */
173 onAskAboutSelection?: (input: PullRequestAskSelectionInput) => void;190 onAskAboutSelection?: (input: PullRequestAskSelectionInput) => void;
...@@ -246,6 +263,7 @@ export function PullRequestCodeTab({...@@ -246,6 +263,7 @@ export function PullRequestCodeTab({
246 patch: data.patch,263 patch: data.patch,
247 truncated: data.truncated,264 truncated: data.truncated,
248 nextCursor: data.nextCursor,265 nextCursor: data.nextCursor,
266 omittedFileStats: data.omittedFileStats ?? [],
249 };267 };
250 const index = slices.findIndex((slice) => slice.cursor === cursor);268 const index = slices.findIndex((slice) => slice.cursor === cursor);
251 if (index === -1) {269 if (index === -1) {
...@@ -256,7 +274,17 @@ export function PullRequestCodeTab({...@@ -256,7 +274,17 @@ export function PullRequestCodeTab({
256 existing !== undefined &&274 existing !== undefined &&
257 existing.patch === next.patch &&275 existing.patch === next.patch &&
258 existing.truncated === next.truncated &&276 existing.truncated === next.truncated &&
259 existing.nextCursor === next.nextCursor277 existing.nextCursor === next.nextCursor &&
278 existing.omittedFileStats.length === next.omittedFileStats.length &&
279 existing.omittedFileStats.every((file, index) => {
280 const refreshed = next.omittedFileStats[index];
281 return (
282 refreshed !== undefined &&
283 refreshed.path === file.path &&
284 refreshed.additions === file.additions &&
285 refreshed.deletions === file.deletions
286 );
287 })
260 ) {288 ) {
261 return previous;289 return previous;
262 }290 }
...@@ -290,6 +318,9 @@ export function PullRequestCodeTab({...@@ -290,6 +318,9 @@ export function PullRequestCodeTab({
290 const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, {318 const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, {
291 reportFailure: false,319 reportFailure: false,
292 });320 });
321 const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
322 reportFailure: false,
323 });
293 const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents);324 const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents);
294 const loadDiffFiles = useMemo(325 const loadDiffFiles = useMemo(
295 () =>326 () =>
...@@ -337,16 +368,12 @@ export function PullRequestCodeTab({...@@ -337,16 +368,12 @@ export function PullRequestCodeTab({
337 }),368 }),
338 [loadedSlices, resolvedTheme, scopeKey],369 [loadedSlices, resolvedTheme, scopeKey],
339 );370 );
340 // Sorted within a slice rather than across them: sorting the accumulated set would let a late371 // Ordered within a slice rather than across them: ordering the accumulated set would let a late
341 // slice push a file the reader is part way through further down the page.372 // slice push a file the reader is part way through further down the page.
342 const files = useMemo(373 const files = useMemo(
343 () =>374 () =>
344 parsedSlices.flatMap((parsed) =>375 parsedSlices.flatMap((parsed) =>
345 parsed?.kind === "files"376 parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [],
346 ? parsed.files.toSorted((left, right) =>
347 resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right)),
348 )
349 : [],
350 ),377 ),
351 [parsedSlices],378 [parsedSlices],
352 );379 );
...@@ -449,7 +476,13 @@ export function PullRequestCodeTab({...@@ -449,7 +476,13 @@ export function PullRequestCodeTab({
449 }:${thread.comments476 }:${thread.comments
450 .map(477 .map(
451 (comment) =>478 (comment) =>
452 `${comment.id}:${comment.author?.login ?? ""}:${comment.createdAt}:${comment.body}`,479 `${comment.id}:${comment.author?.login ?? ""}:${comment.createdAt}:${comment.body}:${(
480 comment.reactions ?? []
481 )
482 .map(
483 (r) => `${r.content}:${r.count}:${r.viewerHasReacted ? "v" : ""}`,
484 )
485 .join(",")}`,
453 )486 )
454 .join(";")}`,487 .join(";")}`,
455 )488 )
...@@ -471,6 +504,15 @@ export function PullRequestCodeTab({...@@ -471,6 +504,15 @@ export function PullRequestCodeTab({
471 ],504 ],
472 );505 );
473 const lineStat = useMemo(() => getDiffLineStat(files), [files]);506 const lineStat = useMemo(() => getDiffLineStat(files), [files]);
507 const omittedFileStats = useMemo(
508 () =>
509 new Map(
510 loadedSlices.flatMap((slice) =>
511 slice.omittedFileStats.map((file) => [file.path, file] as const),
512 ),
513 ),
514 [loadedSlices],
515 );
474 const fileKeys = useMemo(() => items.map((item) => item.id), [items]);516 const fileKeys = useMemo(() => items.map((item) => item.id), [items]);
475 const collapsedFileKeys = useMemo(517 const collapsedFileKeys = useMemo(
476 () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)),518 () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)),
...@@ -649,6 +691,30 @@ export function PullRequestCodeTab({...@@ -649,6 +691,30 @@ export function PullRequestCodeTab({
649 [toggleFile],691 [toggleFile],
650 );692 );
651693
694 const renderHeaderMetadata = useCallback(
695 (item: CodeViewItem<ReviewAnnotationGroup>) => {
696 if (item.type !== "diff") return null;
697 let additions = 0;
698 let deletions = 0;
699 for (const hunk of item.fileDiff.hunks) {
700 additions += hunk.additionLines;
701 deletions += hunk.deletionLines;
702 }
703 if (additions === 0 && deletions === 0) {
704 const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff));
705 if (withheld) ({ additions, deletions } = withheld);
706 }
707 return (
708 <PullRequestDiffStat
709 additions={additions}
710 deletions={deletions}
711 className="font-mono text-[11px]"
712 />
713 );
714 },
715 [omittedFileStats],
716 );
717
652 const diffViewOptions = useMemo(718 const diffViewOptions = useMemo(
653 () => ({719 () => ({
654 diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const),720 diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const),
...@@ -699,13 +765,20 @@ export function PullRequestCodeTab({...@@ -699,13 +765,20 @@ export function PullRequestCodeTab({
699 const renderThreadCard = useCallback(765 const renderThreadCard = useCallback(
700 (thread: PullRequestReviewThread) => (766 (thread: PullRequestReviewThread) => (
701 <ReviewThreadCard767 <ReviewThreadCard
702 key={thread.id}768 // Named with the pull request too: a thread's id is the host's own, and two pull requests
769 // can hand out the same one — which would leave one card's open editor standing over the
770 // other's conversation.
771 key={`${reference.projectId}#${reference.number}:${thread.id}`}
703 thread={thread}772 thread={thread}
704 workspaceRoot={detail.workspaceRoot}773 workspaceRoot={detail.workspaceRoot}
705 canReply={review.reply}774 canReply={review.reply}
706 canResolve={review.resolve}775 canResolve={review.resolve}
776 canReact={detail.capabilities.reactions === true}
777 environmentId={environmentId}
778 reference={reference}
707 pending={threadPending}779 pending={threadPending}
708 fixPending={pendingFinding === pullRequestFindingKey({ kind: "thread", thread })}780 fixPending={pendingFinding === pullRequestFindingKey({ kind: "thread", thread })}
781 fixLabel={fixFindingLabel}
709 {...(onFixFinding ? { onFix: () => onFixFinding({ kind: "thread", thread }) } : {})}782 {...(onFixFinding ? { onFix: () => onFixFinding({ kind: "thread", thread }) } : {})}
710 onReply={(body) =>783 onReply={(body) =>
711 runThreadCommand("Reply could not be posted", () =>784 runThreadCommand("Reply could not be posted", () =>
...@@ -715,6 +788,18 @@ export function PullRequestCodeTab({...@@ -715,6 +788,18 @@ export function PullRequestCodeTab({
715 }),788 }),
716 )789 )
717 }790 }
791 // A conversation on a line is made of review comments, whatever the host filed them as.
792 canEditComment={(comment) =>
793 canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" })
794 }
795 onEditComment={(commentId, body) =>
796 runThreadCommand("The comment could not be saved", () =>
797 updateComment({
798 environmentId,
799 input: { ...reference, commentId, kind: "review-comment", body },
800 }),
801 )
802 }
718 onToggleResolved={() =>803 onToggleResolved={() =>
719 void runThreadCommand("The conversation could not be updated", () =>804 void runThreadCommand("The conversation could not be updated", () =>
720 setThreadResolution({805 setThreadResolution({
...@@ -723,11 +808,14 @@ export function PullRequestCodeTab({...@@ -723,11 +808,14 @@ export function PullRequestCodeTab({
723 }),808 }),
724 )809 )
725 }810 }
811 onReacted={onRefresh}
726 />812 />
727 ),813 ),
728 [814 [
729 detail.workspaceRoot,815 detail,
730 environmentId,816 environmentId,
817 fixFindingLabel,
818 onRefresh,
731 onFixFinding,819 onFixFinding,
732 pendingFinding,820 pendingFinding,
733 reference,821 reference,
...@@ -737,12 +825,13 @@ export function PullRequestCodeTab({...@@ -737,12 +825,13 @@ export function PullRequestCodeTab({
737 runThreadCommand,825 runThreadCommand,
738 setThreadResolution,826 setThreadResolution,
739 threadPending,827 threadPending,
828 updateComment,
740 ],829 ],
741 );830 );
742831
743 const renderAnnotation = useCallback(832 const renderAnnotation = useCallback(
744 (annotation: ReviewAnnotation) => (833 (annotation: ReviewAnnotation) => (
745 <div className="py-1">834 <div className="py-1 font-sans text-foreground">
746 {annotation.metadata.threads.map(renderThreadCard)}835 {annotation.metadata.threads.map(renderThreadCard)}
747 {annotation.metadata.pending.map((comment) => (836 {annotation.metadata.pending.map((comment) => (
748 <PendingReviewCommentCard837 <PendingReviewCommentCard
...@@ -1222,7 +1311,9 @@ export function PullRequestCodeTab({...@@ -1222,7 +1311,9 @@ export function PullRequestCodeTab({
1222 // is running out of diff.1311 // is running out of diff.
1223 renderCodeViewFooter={renderCodeViewFooter}1312 renderCodeViewFooter={renderCodeViewFooter}
1224 renderHeaderPrefix={renderHeaderPrefix}1313 renderHeaderPrefix={renderHeaderPrefix}
1314 renderHeaderMetadata={renderHeaderMetadata}
1225 renderAnnotation={renderAnnotation}1315 renderAnnotation={renderAnnotation}
1316 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1226 />1317 />
1227 {reviewOverlay}1318 {reviewOverlay}
1228 </div>1319 </div>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+554-64
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
5 PullRequestAction,5 PullRequestAction,
6 PullRequestMergeMethod,6 PullRequestMergeMethod,
7 PullRequestUpdateMethod,
7 PullRequestRef,8 PullRequestRef,
8 PullRequestState,9 PullRequestState,
10 ScopedThreadRef,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
10import {12import {
11 ArrowDownUpIcon,13 ArrowDownUpIcon,
...@@ -28,12 +30,15 @@ import {...@@ -28,12 +30,15 @@ import {
28 LinkIcon,30 LinkIcon,
29 MoreHorizontalIcon,31 MoreHorizontalIcon,
30 PanelRightIcon,32 PanelRightIcon,
33 PencilIcon,
31 RefreshCwIcon,34 RefreshCwIcon,
35 ServerIcon,
32 TriangleAlertIcon,36 TriangleAlertIcon,
33} from "lucide-react";37} from "lucide-react";
34import {38import {
35 lazy,39 lazy,
36 Suspense,40 Suspense,
41 type MouseEvent as ReactMouseEvent,
37 useCallback,42 useCallback,
38 useEffect,43 useEffect,
39 useLayoutEffect,44 useLayoutEffect,
...@@ -49,6 +54,8 @@ import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";...@@ -49,6 +54,8 @@ import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
49import { cn } from "~/lib/utils";54import { cn } from "~/lib/utils";
50import { readLocalApi } from "~/localApi";55import { readLocalApi } from "~/localApi";
51import type { ReviewCommentContext } from "~/reviewCommentContext";56import type { ReviewCommentContext } from "~/reviewCommentContext";
57import { useProjects } from "~/state/entities";
58import { useEnvironments } from "~/state/environments";
52import { useEnvironmentQuery } from "~/state/query";59import { useEnvironmentQuery } from "~/state/query";
53import { useLiveRefresh } from "~/hooks/useLiveRefresh";60import { useLiveRefresh } from "~/hooks/useLiveRefresh";
54import { pullRequestEnvironment } from "~/state/pullRequests";61import { pullRequestEnvironment } from "~/state/pullRequests";
...@@ -66,6 +73,7 @@ import {...@@ -66,6 +73,7 @@ import {
66} from "../ui/alert-dialog";73} from "../ui/alert-dialog";
67import { Badge } from "../ui/badge";74import { Badge } from "../ui/badge";
68import { Button } from "../ui/button";75import { Button } from "../ui/button";
76import { Input } from "../ui/input";
69import {77import {
70 Menu,78 Menu,
71 MenuItem,79 MenuItem,
...@@ -75,12 +83,14 @@ import {...@@ -75,12 +83,14 @@ import {
75 MenuSeparator,83 MenuSeparator,
76 MenuTrigger,84 MenuTrigger,
77} from "../ui/menu";85} from "../ui/menu";
86import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
78import { toastManager } from "../ui/toast";87import { toastManager } from "../ui/toast";
79import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";88import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
80import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";89import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
81import { DiffPanelLoadingState } from "../DiffPanelShell";90import { DiffPanelLoadingState } from "../DiffPanelShell";
82import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";91import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
83import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab";92import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab";
93import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
84import { PullRequestSummaryTab } from "./PullRequestSummaryTab";94import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
85import { PullRequestTimelineTab } from "./PullRequestTimelineTab";95import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
86import {96import {
...@@ -92,14 +102,24 @@ import {...@@ -92,14 +102,24 @@ import {
92 buildResolveConflictsPrompt,102 buildResolveConflictsPrompt,
93 handoffPrompt,103 handoffPrompt,
94 handoffReviewComments,104 handoffReviewComments,
105 pullRequestActionNeedsHostRefresh,
95 pullRequestFindingKey,106 pullRequestFindingKey,
107 pullRequestHandoffLabels,
96 readableFailure,108 readableFailure,
109 resolveBaseFreshness,
97 type PullRequestFinding,110 type PullRequestFinding,
98} from "./pullRequestDetail.logic";111} from "./pullRequestDetail.logic";
112import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic";
113import {
114 resolvePickableEnvironments,
115 type PickableEnvironment,
116} from "./pullRequestProjectAssignment.logic";
117import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
99import {118import {
100 PullRequestActorLabel,119 PullRequestActorLabel,
101 PullRequestDiffStat,120 PullRequestDiffStat,
102 PullRequestMetaLine,121 PullRequestMetaLine,
122 pullRequestChecksState,
103 resolvePullRequestState,123 resolvePullRequestState,
104 summarizePullRequestChecks,124 summarizePullRequestChecks,
105} from "./pullRequestPresentation";125} from "./pullRequestPresentation";
...@@ -112,6 +132,12 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {...@@ -112,6 +132,12 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {
112 draft: "Converted to draft",132 draft: "Converted to draft",
113 close: "Pull request closed",133 close: "Pull request closed",
114 reopen: "Pull request reopened",134 reopen: "Pull request reopened",
135 "update-branch": "Branch updated with the base branch",
136 // True whichever it did: a pull request that was already mergeable merges the moment this is
137 // armed, and the client has no way to tell that apart from one still waiting on something.
138 "enable-auto-merge":
139 "Auto-merge turned on — merges as soon as this is ready, sooner if it already is",
140 "disable-auto-merge": "Auto-merge turned off",
115};141};
116142
117/** Said as the thing that did not happen, rather than as the operation that returned an error. */143/** Said as the thing that did not happen, rather than as the operation that returned an error. */
...@@ -121,6 +147,9 @@ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {...@@ -121,6 +147,9 @@ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
121 draft: "Could not convert this to a draft",147 draft: "Could not convert this to a draft",
122 close: "Could not close this pull request",148 close: "Could not close this pull request",
123 reopen: "Could not reopen this pull request",149 reopen: "Could not reopen this pull request",
150 "update-branch": "Could not update this branch",
151 "enable-auto-merge": "Could not turn on auto-merge",
152 "disable-auto-merge": "Could not turn off auto-merge",
124};153};
125154
126/** What to try, for the times the host says only that it refused. */155/** What to try, for the times the host says only that it refused. */
...@@ -132,15 +161,25 @@ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {...@@ -132,15 +161,25 @@ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {
132 close: "The host refused it. Check that you have write access, or that you opened it.",161 close: "The host refused it. Check that you have write access, or that you opened it.",
133 reopen:162 reopen:
134 "The host refused it. Check that you have write access, and that the branch still exists.",163 "The host refused it. Check that you have write access, and that the branch still exists.",
164 // Said for the merge commit, which is what an update is unless a rebase was asked for. The
165 // rebase has its own reasons to fail and its own sentence below.
166 "update-branch":
167 "The host refused it. Check that you have write access to the branch — one from a fork also needs its author to allow edits from maintainers — and that it does not conflict with the base.",
168 // The one refusal that is usually a repository setting rather than anything about this branch:
169 // GitHub will not arm an auto-merge at all unless the repository has the feature switched on.
170 "enable-auto-merge":
171 "The host refused it. Check that this repository allows auto-merge, that you have write access, and that there is something left for it to wait on.",
172 "disable-auto-merge":
173 "The host refused it. Check that you have write access, and that the merge has not already happened.",
135};174};
136175
137/** Named for the host rather than "externally": the point is where you will land. */176/**
138const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {177 * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on
139 github: "Open on GitHub",178 * its own merits, because GitHub replays the commits and stops at the first that does not apply.
140 gitlab: "Open on GitLab",179 * Offering the merge commit only makes sense to somebody who did not already choose it.
141 bitbucket: "Open on Bitbucket",180 */
142 "azure-devops": "Open on Azure DevOps",181const UPDATE_BRANCH_REBASE_FAILURE_HINT =
143};182 "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work.";
144183
145const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [184const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [
146 { value: "summary", label: "Summary" },185 { value: "summary", label: "Summary" },
...@@ -159,7 +198,148 @@ const PullRequestCodeTab = lazy(loadCodeTab);...@@ -159,7 +198,148 @@ const PullRequestCodeTab = lazy(loadCodeTab);
159 * is closed by the time the next one opens. It is how a prompt the reader has since edited is told198 * is closed by the time the next one opens. It is how a prompt the reader has since edited is told
160 * apart from the one they were handed: only the sentence still exactly as written may be replaced.199 * apart from the one they were handed: only the sentence still exactly as written may be replaced.
161 */200 */
162const lastHandoffPromptByDraft = new Map<DraftId, string>();201const lastHandoffPromptByDraft = new Map<string, string>();
202
203const composerTargetKey = (target: ScopedThreadRef | DraftId): string =>
204 typeof target === "string" ? target : scopedThreadKey(target);
205
206/**
207 * Which server the checkout and the hand-offs land on, where more than one of them holds this
208 * repository. The list picked one of them to show the pull request under, so that everything on
209 * it is read from somewhere; where the reader wants to work is a separate answer, and this is
210 * where they give it.
211 */
212function ActOnEnvironmentPicker({
213 environments,
214 value,
215 onChange,
216 disabled,
217}: {
218 environments: ReadonlyArray<PickableEnvironment>;
219 value: EnvironmentId;
220 onChange: (environmentId: EnvironmentId) => void;
221 disabled: boolean;
222}) {
223 return (
224 <>
225 <MenuSeparator />
226 <MenuRadioGroup
227 value={value}
228 onValueChange={(environmentId) => onChange(environmentId as EnvironmentId)}
229 >
230 {environments.map((environment) => (
231 <MenuRadioItem
232 key={environment.environmentId}
233 value={environment.environmentId}
234 disabled={disabled}
235 >
236 {/* The radio item lays its children out as one block, so the icon and the label
237 need their own row to share a line. */}
238 <span className="flex min-w-0 items-center gap-2">
239 <ServerIcon className="size-3.5 shrink-0" />
240 <span className="truncate">{environment.label}</span>
241 </span>
242 </MenuRadioItem>
243 ))}
244 </MenuRadioGroup>
245 </>
246 );
247}
248
249/** The number is a link in every place the host writes it, so the right-click that copies one
250 has to answer here too — otherwise the platform's own cut/paste menu opens over it. */
251const openNumberContextMenu = (
252 event: ReactMouseEvent,
253 detail: { readonly url: string; readonly provider: string },
254): void => {
255 event.preventDefault();
256 event.stopPropagation();
257 void showPullRequestLinkContextMenu({
258 url: detail.url,
259 openLabel: openOnHostLabel(detail.provider),
260 position: { x: event.clientX, y: event.clientY },
261 });
262};
263
264/**
265 * The stale-branch warning, said beside the branch it is about rather than as a bar of its own.
266 * The banner this replaces held a row of chrome open across the top of every pull request that
267 * had fallen behind, pushing the reading down to say something that is true of the base branch
268 * and nothing else; as a mark on the base branch it is where a reader would look for it, and the
269 * sentence and the way out of it arrive together the moment the mark is pointed at.
270 *
271 * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes
272 * no pointer, and a control nobody can reach is worse than no control.
273 */
274function PullRequestBaseFreshnessWarning({
275 baseBranch,
276 freshness,
277 pending,
278 onUpdate,
279 iconClassName,
280}: {
281 readonly baseBranch: string;
282 readonly freshness: {
283 readonly behindBy: number | null;
284 readonly methods: ReadonlyArray<PullRequestUpdateMethod>;
285 };
286 readonly pending: boolean;
287 readonly onUpdate: (method: PullRequestUpdateMethod) => void;
288 readonly iconClassName?: string;
289}) {
290 const behind =
291 freshness.behindBy === null
292 ? ""
293 : ` by ${freshness.behindBy.toLocaleString()} ${
294 freshness.behindBy === 1 ? "commit" : "commits"
295 }`;
296 const summary = `This branch is out-of-date with ${baseBranch}${behind}.`;
297 return (
298 <Popover>
299 <PopoverTrigger
300 openOnHover
301 delay={0}
302 closeDelay={120}
303 render={
304 <button
305 type="button"
306 aria-label={summary}
307 className="inline-flex shrink-0 cursor-help items-center rounded-sm text-amber-600 outline-none focus-visible:ring-2 focus-visible:ring-ring"
308 />
309 }
310 >
311 <TriangleAlertIcon aria-hidden className={cn("size-3.5", iconClassName)} />
312 </PopoverTrigger>
313 <PopoverPopup
314 align="start"
315 side="bottom"
316 className="max-w-80"
317 viewportClassName="py-2.5 [--viewport-inline-padding:--spacing(3)]"
318 >
319 <p className="text-xs text-foreground">{summary}</p>
320 <p className="mt-0.5 text-xs text-muted-foreground">Changes can be cleanly merged.</p>
321 {/* Each way the host offers and this reader may take, as its own button: a split button
322 would need a menu inside a popover, and two buttons say the same thing in one layer. */}
323 {freshness.methods.length > 0 ? (
324 <span className="mt-2 flex flex-wrap items-center gap-1.5">
325 {freshness.methods.map((method) => (
326 <Button
327 key={method}
328 size="xs"
329 variant="outline"
330 disabled={pending}
331 onClick={() => onUpdate(method)}
332 >
333 <GitMergeIcon aria-hidden className="size-3" />
334 {method === "rebase" ? "Update with rebase" : "Update branch"}
335 </Button>
336 ))}
337 </span>
338 ) : null}
339 </PopoverPopup>
340 </Popover>
341 );
342}
163343
164export function PullRequestDetailPanel({344export function PullRequestDetailPanel({
165 environmentId,345 environmentId,
...@@ -170,6 +350,7 @@ export function PullRequestDetailPanel({...@@ -170,6 +350,7 @@ export function PullRequestDetailPanel({
170 onStateChange,350 onStateChange,
171 context = "page",351 context = "page",
172 chromeVariant = "full",352 chromeVariant = "full",
353 composerDraftTarget,
173}: {354}: {
174 environmentId: EnvironmentId;355 environmentId: EnvironmentId;
175 reference: PullRequestRef;356 reference: PullRequestRef;
...@@ -206,6 +387,11 @@ export function PullRequestDetailPanel({...@@ -206,6 +387,11 @@ export function PullRequestDetailPanel({
206 * top — the chrome spends its height on what is being read.387 * top — the chrome spends its height on what is being read.
207 */388 */
208 chromeVariant?: "full" | "collapse";389 chromeVariant?: "full" | "collapse";
390 /**
391 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
392 * land here instead of opening a new thread — the branch is already under the reader's feet.
393 */
394 composerDraftTarget?: ScopedThreadRef | DraftId;
209}) {395}) {
210 const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;396 const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;
211 const [tab, setTab] = useState<DetailTab>("summary");397 const [tab, setTab] = useState<DetailTab>("summary");
...@@ -267,7 +453,9 @@ export function PullRequestDetailPanel({...@@ -267,7 +453,9 @@ export function PullRequestDetailPanel({
267 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);453 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
268 }, [condensed]);454 }, [condensed]);
269 const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");455 const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");
270 const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null);456 const [confirmAction, setConfirmAction] = useState<
457 "merge" | "close" | "enable-auto-merge" | null
458 >(null);
271 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself459 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
272 // alone. One at a time whatever the key: they all check the same pull request out.460 // alone. One at a time whatever the key: they all check the same pull request out.
273 const [handoff, setHandoff] = useState<string | null>(null);461 const [handoff, setHandoff] = useState<string | null>(null);
...@@ -310,6 +498,7 @@ export function PullRequestDetailPanel({...@@ -310,6 +498,7 @@ export function PullRequestDetailPanel({
310 commentsTruncated: activity?.commentsTruncated ?? false,498 commentsTruncated: activity?.commentsTruncated ?? false,
311 reviewThreads: activity?.reviewThreads ?? [],499 reviewThreads: activity?.reviewThreads ?? [],
312 commits: activity?.commits ?? [],500 commits: activity?.commits ?? [],
501 reactions: activity?.reactions ?? [],
313 },502 },
314 [activity, coreDetail],503 [activity, coreDetail],
315 );504 );
...@@ -355,44 +544,157 @@ export function PullRequestDetailPanel({...@@ -355,44 +544,157 @@ export function PullRequestDetailPanel({
355 void refreshFromHost();544 void refreshFromHost();
356 }, [forcedRefreshToken, refreshFromHost]);545 }, [forcedRefreshToken, refreshFromHost]);
357 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });546 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
358 const [actionPending, setActionPending] = useState(false);547 // Which action is in flight, not merely that one is: every control here is disabled while any
548 // of them runs, but only the button that was pressed may say what it is doing.
549 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);
550 const actionPending = pendingAction !== null;
551 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
552 // Scoped to the pull request it was typed against, since this one panel shows a different one
553 // every time it is opened and a half-written title must not follow it there.
554 const [titleScope, setTitleScope] = useState<{
555 readonly pullRequestKey: string;
556 readonly text: string;
557 } | null>(null);
558 const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null;
559 const [titleSaving, setTitleSaving] = useState(false);
359 const newThread = useNewThreadHandler();560 const newThread = useNewThreadHandler();
561 const { environments } = useEnvironments();
562 const projects = useProjects();
563 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
564 // the thread is already on one server's copy of the branch.
565 const pickableEnvironments = useMemo(
566 () =>
567 context === "page"
568 ? resolvePickableEnvironments(
569 { environmentId, projectId: reference.projectId },
570 projects,
571 environments,
572 )
573 : [],
574 [context, environmentId, environments, projects, reference.projectId],
575 );
576 // Which server the reader chose, and only for the pull request they chose it on: this one panel
577 // shows a different pull request every time it is opened, and the choice does not follow.
578 const [actingScope, setActingScope] = useState<{
579 readonly pullRequestKey: string;
580 readonly environmentId: EnvironmentId;
581 } | null>(null);
582 const chosenEnvironmentId =
583 actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId;
584 // Null wherever there is no choice on offer — one server, or a chosen one that has since gone —
585 // and then the panel's own server and its own checkout are the answer, as they always were.
586 const acting =
587 pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null;
588 const actingEnvironmentId = acting?.environmentId ?? environmentId;
360 const prepareThread = usePreparePullRequestThreadAction({589 const prepareThread = usePreparePullRequestThreadAction({
361 environmentId,590 environmentId: actingEnvironmentId,
362 cwd: detail?.workspaceRoot ?? null,591 cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null,
363 });592 });
364593
365 const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => {594 const perform = async (
366 if (actionPending) return;595 action: PullRequestAction,
367 setActionPending(true);596 method?: PullRequestMergeMethod,
597 updateMethod?: PullRequestUpdateMethod,
598 ) => {
599 if (pendingAction !== null) return;
600 setPendingAction(action);
368 const result = await runAction({601 const result = await runAction({
369 environmentId,602 environmentId,
370 input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) },603 input: {
604 ...reference,
605 action,
606 ...(method ? { mergeMethod: method } : {}),
607 ...(updateMethod ? { updateMethod } : {}),
608 },
371 });609 });
372 setActionPending(false);610 setPendingAction(null);
373 if (result._tag === "Failure") {611 if (result._tag === "Failure") {
374 // The host's own sentence, because it is the only thing that says why. A merge strategy a612 // The host's own sentence, because it is the only thing that says why. A merge strategy a
375 // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps613 // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps
376 // publishes no per-strategy availability to hide the control with — so "action failed"614 // publishes no per-strategy availability to hide the control with — so "action failed"
377 // would leave the reader pressing the same button again.615 // would leave the reader pressing the same button again.
378 const failure = squashAtomCommandFailure(result);616 const failure = squashAtomCommandFailure(result);
617 // The hint stands for what was actually asked for: a reader who pressed Update branch is
618 // told to check their access, not offered the merge commit they already chose.
619 const hint =
620 updateMethod === "rebase"
621 ? UPDATE_BRANCH_REBASE_FAILURE_HINT
622 : ACTION_FAILURE_HINTS[action];
379 toastManager.add({623 toastManager.add({
380 type: "error",624 type: "error",
381 title: ACTION_FAILURE_LABELS[action],625 title: ACTION_FAILURE_LABELS[action],
382 description: readableFailure(failure, ACTION_FAILURE_HINTS[action]),626 description: readableFailure(failure, hint),
383 });627 });
384 return;628 return;
385 }629 }
386 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });630 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
387 refreshDetail();631 // A branch update moves the head commit, which leaves the diff atom pointed at a comparison
632 // that no longer exists — the same staleness the manual refresh button fixes, so it goes
633 // through that path rather than a second one. Every other action here only changes metadata;
634 // a merge does move the branch too, but it also closes the pull request, where the diff is
635 // no longer what anyone is looking at.
636 if (pullRequestActionNeedsHostRefresh(action)) {
637 void refreshFromHost();
638 } else {
639 refreshDetail();
640 }
388 onActed?.();641 onActed?.();
389 };642 };
390643
644 const saveTitle = async (next: string) => {
645 const title = next.trim();
646 if (detail === null || titleSaving) return;
647 if (title.length === 0 || title === detail.title) {
648 setTitleScope(null);
649 return;
650 }
651 setTitleSaving(true);
652 const result = await update({ environmentId, input: { ...reference, title } });
653 setTitleSaving(false);
654 if (result._tag === "Failure") {
655 // The draft stays open with the words still in it: retyping a title somebody has just
656 // rewritten is the one thing a failed save must not cost them.
657 toastManager.add({
658 type: "error",
659 title: "The title could not be saved",
660 description: readableFailure(
661 squashAtomCommandFailure(result),
662 "The host refused the new title.",
663 ),
664 });
665 return;
666 }
667 setTitleScope(null);
668 refreshDetail();
669 };
670
391 type ThreadTask = {671 type ThreadTask = {
392 prompt: string;672 prompt: string;
393 reviewComments?: ReadonlyArray<ReviewCommentContext>;673 reviewComments?: ReadonlyArray<ReviewCommentContext>;
394 };674 };
395675
676 // Beside the thread whose own pull request this is, a task belongs in that thread's composer:
677 // the branch is already checked out under it, so opening a second thread would only scatter
678 // the work.
679 const attachTarget = context === "thread" ? (composerDraftTarget ?? null) : null;
680 const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);
681
682 const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
683 const store = useComposerDraftStore.getState();
684 const draft = store.getComposerDraft(target);
685 const key = composerTargetKey(target);
686 const prompt = handoffPrompt(
687 { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(key) },
688 task.prompt,
689 );
690 lastHandoffPromptByDraft.set(key, task.prompt);
691 store.setPrompt(target, prompt);
692 store.setReviewComments(
693 target,
694 handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []),
695 );
696 };
697
396 /**698 /**
397 * Opens a thread on this project and leaves the task in its composer for the reader to send.699 * Opens a thread on this project and leaves the task in its composer for the reader to send.
398 *700 *
...@@ -412,37 +714,32 @@ export function PullRequestDetailPanel({...@@ -412,37 +714,32 @@ export function PullRequestDetailPanel({
412 () => null,714 () => null,
413 ));715 ));
414 if (session === null) return null;716 if (session === null) return null;
415 const store = useComposerDraftStore.getState();
416 if (task === null) return session;717 if (task === null) return session;
417 // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips718 // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips
418 // both, rather than stacking a second one under the first. What the reader typed themselves719 // both, rather than stacking a second one under the first. What the reader typed themselves
419 // survives — the composer they are handed is not always a fresh one, and a prompt they have720 // survives — the composer they are handed is not always a fresh one, and a prompt they have
420 // since edited is theirs rather than the hand-off's.721 // since edited is theirs rather than the hand-off's.
421 const draft = store.getComposerDraft(session.draftId);722 writeTaskToComposer(session.draftId, task);
422 const existingComments = draft?.reviewComments ?? [];
423 const prompt = handoffPrompt(
424 {
425 prompt: draft?.prompt ?? "",
426 lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId),
427 },
428 task.prompt,
429 );
430 // Remember the hand-off's own contribution, not the merged prompt: only that sentence is
431 // this session's to take back next time, and the reader's text around it is not.
432 lastHandoffPromptByDraft.set(session.draftId, task.prompt);
433 store.setPrompt(session.draftId, prompt);
434 store.setReviewComments(
435 session.draftId,
436 handoffReviewComments(existingComments, task.reviewComments ?? []),
437 );
438 return session;723 return session;
439 };724 };
440725
441 /** A question about the change, which needs a thread and nothing else. */726 /** A question about the change, which needs a thread and nothing else. */
442 const startAsk = async (kind: string, task: ThreadTask) => {727 const startAsk = async (kind: string, task: ThreadTask) => {
443 if (!detail || handoff !== null) return;728 if (!detail || handoff !== null) return;
729 if (attachTarget !== null) {
730 writeTaskToComposer(attachTarget, task);
731 toastManager.add({
732 type: "success",
733 title: "Added to the composer",
734 description:
735 task.prompt.length > 0
736 ? "The question is in the composer — read it over, then send."
737 : "The pull request is in the composer — type your question, then send.",
738 });
739 return;
740 }
444 setHandoff(kind);741 setHandoff(kind);
445 const projectRef = scopeProjectRef(environmentId, detail.projectId);742 const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId);
446 const opened = await openThreadWithTask(projectRef, task);743 const opened = await openThreadWithTask(projectRef, task);
447 setHandoff(null);744 setHandoff(null);
448 if (opened === null) {745 if (opened === null) {
...@@ -477,6 +774,15 @@ export function PullRequestDetailPanel({...@@ -477,6 +774,15 @@ export function PullRequestDetailPanel({
477 mode: "worktree" | "local" = "worktree",774 mode: "worktree" | "local" = "worktree",
478 ) => {775 ) => {
479 if (!detail || handoff !== null) return;776 if (!detail || handoff !== null) return;
777 if (attachTarget !== null && task !== null) {
778 writeTaskToComposer(attachTarget, task);
779 toastManager.add({
780 type: "success",
781 title: "Added to the composer",
782 description: "The task is in the composer — read it over, then send.",
783 });
784 return;
785 }
480 setHandoff(kind);786 setHandoff(kind);
481 // The menu closes on the press and takes its "Preparing..." label with it, so this is the787 // The menu closes on the press and takes its "Preparing..." label with it, so this is the
482 // only thing answering for the checkout. It carries no timeout of its own: a loading toast788 // only thing answering for the checkout. It carries no timeout of its own: a loading toast
...@@ -485,7 +791,9 @@ export function PullRequestDetailPanel({...@@ -485,7 +791,9 @@ export function PullRequestDetailPanel({
485 type: "loading",791 type: "loading",
486 title: "Preparing the pull request checkout...",792 title: "Preparing the pull request checkout...",
487 });793 });
488 const projectRef = scopeProjectRef(environmentId, detail.projectId);794 // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer
795 // the task lands in are all one server's, and picking another one moves all three.
796 const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId);
489 // The thread is opened before the checkout rather than after it, because the project's setup797 // The thread is opened before the checkout rather than after it, because the project's setup
490 // script only runs for a checkout that knows which thread it is for — and a worktree with no798 // script only runs for a checkout that knows which thread it is for — and a worktree with no
491 // dependencies installed is not something anyone can test.799 // dependencies installed is not something anyone can test.
...@@ -688,6 +996,12 @@ export function PullRequestDetailPanel({...@@ -688,6 +996,12 @@ export function PullRequestDetailPanel({
688 ? mergeMethod996 ? mergeMethod
689 : (allowedMergeMethods[0] ?? "merge");997 : (allowedMergeMethods[0] ?? "merge");
690 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";998 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
999 // Only an outright yes arms it. A host that reports nothing has not said the merge is already
1000 // spoken for, and an off switch for something that may not be on says the wrong thing twice.
1001 const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;
1002 // Out of date with the base, and still cleanly mergeable — the one pairing an update button
1003 // exists for. Null everywhere else, including hosts that cannot compare at all.
1004 const freshness = detail === null ? null : resolveBaseFreshness(detail);
691 // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden1005 // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden
692 // until the detail arrives, so the loading ghost is the panel's only unfinished UI.1006 // until the detail arrives, so the loading ghost is the panel's only unfinished UI.
693 const visibleTabs = TABS.filter(1007 const visibleTabs = TABS.filter(
...@@ -725,6 +1039,7 @@ export function PullRequestDetailPanel({...@@ -725,6 +1039,7 @@ export function PullRequestDetailPanel({
725 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })1039 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
726 : null;1040 : null;
727 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;1041 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
1042 const checksState = detail ? pullRequestChecksState(detail.checks) : null;
7281043
729 return (1044 return (
730 <div className="flex h-full min-h-0 w-full flex-col bg-background">1045 <div className="flex h-full min-h-0 w-full flex-col bg-background">
...@@ -755,11 +1070,12 @@ export function PullRequestDetailPanel({...@@ -755,11 +1070,12 @@ export function PullRequestDetailPanel({
755 <button1070 <button
756 type="button"1071 type="button"
757 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1072 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1073 onContextMenu={(event) => openNumberContextMenu(event, detail)}
758 className={cn(1074 className={cn(
759 "shrink-0 font-medium underline-offset-2 hover:underline",1075 "shrink-0 font-medium underline-offset-2 hover:underline",
760 statePresentation.toneClassName,1076 statePresentation.toneClassName,
761 )}1077 )}
762 title={OPEN_ON_HOST_LABELS[detail.provider] ?? "Open on host"}1078 title={openOnHostLabel(detail.provider)}
763 aria-label={`Open pull request #${detail.number} on host`}1079 aria-label={`Open pull request #${detail.number} on host`}
764 >1080 >
765 #{detail.number}1081 #{detail.number}
...@@ -783,11 +1099,12 @@ export function PullRequestDetailPanel({...@@ -783,11 +1099,12 @@ export function PullRequestDetailPanel({
783 type="button"1099 type="button"
784 tabIndex={condensed ? 0 : -1}1100 tabIndex={condensed ? 0 : -1}
785 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1101 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1102 onContextMenu={(event) => openNumberContextMenu(event, detail)}
786 className={cn(1103 className={cn(
787 "shrink-0 font-medium underline-offset-2 hover:underline",1104 "shrink-0 font-medium underline-offset-2 hover:underline",
788 statePresentation.toneClassName,1105 statePresentation.toneClassName,
789 )}1106 )}
790 title={OPEN_ON_HOST_LABELS[detail.provider] ?? "Open on host"}1107 title={openOnHostLabel(detail.provider)}
791 aria-label={`Open pull request #${detail.number} on host`}1108 aria-label={`Open pull request #${detail.number} on host`}
792 >1109 >
793 #{detail.number}1110 #{detail.number}
...@@ -804,7 +1121,10 @@ export function PullRequestDetailPanel({...@@ -804,7 +1121,10 @@ export function PullRequestDetailPanel({
804 Conflicts1121 Conflicts
805 </Badge>1122 </Badge>
806 ) : checksSummary ? (1123 ) : checksSummary ? (
807 <span className="shrink-0 text-[10px] text-muted-foreground">1124 <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
1125 {detail && checksState !== null ? (
1126 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1127 ) : null}
808 {checksSummary}1128 {checksSummary}
809 </span>1129 </span>
810 ) : null}1130 ) : null}
...@@ -832,7 +1152,9 @@ export function PullRequestDetailPanel({...@@ -832,7 +1152,9 @@ export function PullRequestDetailPanel({
832 <span className="flex min-w-0 flex-col">1152 <span className="flex min-w-0 flex-col">
833 <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>1153 <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
834 <span className="text-xs text-muted-foreground">1154 <span className="text-xs text-muted-foreground">
835 Opens a thread that knows which pull request you mean.1155 {attachTarget !== null
1156 ? "Adds the pull request to this thread's composer."
1157 : "Opens a thread that knows which pull request you mean."}
836 </span>1158 </span>
837 </span>1159 </span>
838 </MenuItem>1160 </MenuItem>
...@@ -847,8 +1169,16 @@ export function PullRequestDetailPanel({...@@ -847,8 +1169,16 @@ export function PullRequestDetailPanel({
847 </MenuItem>1169 </MenuItem>
848 <MenuItem disabled={handoff !== null} onClick={startFixFindings}>1170 <MenuItem disabled={handoff !== null} onClick={startFixFindings}>
849 <HammerIcon className="size-3.5" />1171 <HammerIcon className="size-3.5" />
850 {handoff === "findings" ? "Preparing..." : "Fix findings in a thread"}1172 {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
851 </MenuItem>1173 </MenuItem>
1174 {pickableEnvironments.length > 0 ? (
1175 <ActOnEnvironmentPicker
1176 environments={pickableEnvironments}
1177 value={actingEnvironmentId}
1178 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
1179 disabled={handoff !== null}
1180 />
1181 ) : null}
852 <MenuSeparator />1182 <MenuSeparator />
853 {detail.state === "open" ? (1183 {detail.state === "open" ? (
854 <>1184 <>
...@@ -869,6 +1199,32 @@ export function PullRequestDetailPanel({...@@ -869,6 +1199,32 @@ export function PullRequestDetailPanel({
869 {detail.isDraft ? "Ready for review" : "Convert to draft"}1199 {detail.isDraft ? "Ready for review" : "Convert to draft"}
870 </MenuItem>1200 </MenuItem>
871 ) : null}1201 ) : null}
1202 {/* The same merge, left with the host to carry out once the things it
1203 waits on are done. It is offered beside the merge rather than instead
1204 of it, because the reader who can wait and the reader who cannot are
1205 the same person on different days — and a conflicting branch is neither,
1206 since nothing the host waits for will clear it. */}
1207 {autoMergeArmed && can("disable-auto-merge") ? (
1208 <MenuItem
1209 disabled={actionPending}
1210 onClick={() => void perform("disable-auto-merge")}
1211 >
1212 <GitMergeIcon className="size-3.5" />
1213 Disable auto-merge
1214 </MenuItem>
1215 ) : !autoMergeArmed &&
1216 !detail.isDraft &&
1217 !conflicting &&
1218 can("enable-auto-merge") &&
1219 allowedMergeMethods.length > 0 ? (
1220 <MenuItem
1221 disabled={actionPending}
1222 onClick={() => setConfirmAction("enable-auto-merge")}
1223 >
1224 <GitMergeIcon className="size-3.5" />
1225 Enable auto-merge
1226 </MenuItem>
1227 ) : null}
872 {/* A preference for the merge action rather than a second action, so it1228 {/* A preference for the merge action rather than a second action, so it
873 is a radio group here instead of a chevron welded to the Merge pill.1229 is a radio group here instead of a chevron welded to the Merge pill.
874 Hidden while conflicting: every method would fail. */}1230 Hidden while conflicting: every method would fail. */}
...@@ -904,7 +1260,7 @@ export function PullRequestDetailPanel({...@@ -904,7 +1260,7 @@ export function PullRequestDetailPanel({
904 ) : null}1260 ) : null}
905 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>1261 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>
906 <ArrowUpRightIcon className="size-3.5" />1262 <ArrowUpRightIcon className="size-3.5" />
907 {OPEN_ON_HOST_LABELS[detail.provider] ?? "Open on host"}1263 {openOnHostLabel(detail.provider)}
908 </MenuItem>1264 </MenuItem>
909 <MenuItem onClick={() => void writeTextToClipboard(detail.url)}>1265 <MenuItem onClick={() => void writeTextToClipboard(detail.url)}>
910 <LinkIcon className="size-3.5" />1266 <LinkIcon className="size-3.5" />
...@@ -914,7 +1270,7 @@ export function PullRequestDetailPanel({...@@ -914,7 +1270,7 @@ export function PullRequestDetailPanel({
914 {conflicting && primaryAction !== "resolve" ? (1270 {conflicting && primaryAction !== "resolve" ? (
915 <MenuItem disabled={handoff !== null} onClick={startResolveConflicts}>1271 <MenuItem disabled={handoff !== null} onClick={startResolveConflicts}>
916 <GitMergeIcon className="size-3.5" />1272 <GitMergeIcon className="size-3.5" />
917 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts in a thread"}1273 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolveConflicts}
918 </MenuItem>1274 </MenuItem>
919 ) : null}1275 ) : null}
920 {detail.state === "open" && can("close") ? (1276 {detail.state === "open" && can("close") ? (
...@@ -982,9 +1338,29 @@ export function PullRequestDetailPanel({...@@ -982,9 +1338,29 @@ export function PullRequestDetailPanel({
982 </span>1338 </span>
983 </span>1339 </span>
984 </MenuItem>1340 </MenuItem>
1341 {pickableEnvironments.length > 0 ? (
1342 <ActOnEnvironmentPicker
1343 environments={pickableEnvironments}
1344 value={actingEnvironmentId}
1345 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
1346 disabled={handoff !== null}
1347 />
1348 ) : null}
985 </MenuPopup>1349 </MenuPopup>
986 </Menu>1350 </Menu>
987 ) : null}1351 ) : null}
1352 {/* Said where the Merge button is, because it is the answer to why nobody has
1353 pressed it: the merge is already asked for, and the host is holding it. */}
1354 {autoMergeArmed ? (
1355 <Badge
1356 variant="info"
1357 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1358 title="The host will merge this on its own once its requirements are met"
1359 >
1360 <GitMergeIcon aria-hidden className="size-3" />
1361 Auto-merge
1362 </Badge>
1363 ) : null}
988 {primaryAction === "ready" ? (1364 {primaryAction === "ready" ? (
989 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>1365 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
990 Ready for review1366 Ready for review
...@@ -995,7 +1371,7 @@ export function PullRequestDetailPanel({...@@ -995,7 +1371,7 @@ export function PullRequestDetailPanel({
995 disabled={actionPending}1371 disabled={actionPending}
996 onClick={() => setConfirmAction("merge")}1372 onClick={() => setConfirmAction("merge")}
997 >1373 >
998 {actionPending ? "Merging..." : "Merge"}1374 {pendingAction === "merge" ? "Merging..." : "Merge"}
999 </Button>1375 </Button>
1000 ) : null}1376 ) : null}
1001 </>1377 </>
...@@ -1015,7 +1391,16 @@ export function PullRequestDetailPanel({...@@ -1015,7 +1391,16 @@ export function PullRequestDetailPanel({
1015 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,1391 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,
1016 and compact copies of the branch pair and diff stat so they stay in sight while1392 and compact copies of the branch pair and diff stat so they stay in sight while
1017 the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}1393 the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
1018 <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>1394 <div
1395 className={cn(
1396 "col-span-2 grid",
1397 // Inverse of the fold's track: closing eases while the fold above eases open,
1398 // so the chrome trades rows in one motion instead of two jumps.
1399 condensed
1400 ? "grid-rows-[1fr]"
1401 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1402 )}
1403 >
1019 <div1404 <div
1020 ref={condensedRowRef}1405 ref={condensedRowRef}
1021 className={cn(1406 className={cn(
...@@ -1052,6 +1437,15 @@ export function PullRequestDetailPanel({...@@ -1052,6 +1437,15 @@ export function PullRequestDetailPanel({
1052 title={`${detail.baseBranch} ← ${detail.headBranch}`}1437 title={`${detail.baseBranch} ← ${detail.headBranch}`}
1053 >1438 >
1054 <span className="truncate">{detail.baseBranch}</span>1439 <span className="truncate">{detail.baseBranch}</span>
1440 {freshness ? (
1441 <PullRequestBaseFreshnessWarning
1442 baseBranch={detail.baseBranch}
1443 freshness={freshness}
1444 pending={actionPending}
1445 onUpdate={(method) => void perform("update-branch", undefined, method)}
1446 iconClassName="size-3"
1447 />
1448 ) : null}
1055 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />1449 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />
1056 <span className="truncate">{detail.headBranch}</span>1450 <span className="truncate">{detail.headBranch}</span>
1057 </span>1451 </span>
...@@ -1077,10 +1471,13 @@ export function PullRequestDetailPanel({...@@ -1077,10 +1471,13 @@ export function PullRequestDetailPanel({
1077 <div1471 <div
1078 className={cn(1472 className={cn(
1079 "col-span-2 grid",1473 "col-span-2 grid",
1080 // Instant in both directions: the scroll compensation keeps the content pinned1474 // Collapse is instant: it happens mid-scroll, and the compensation that keeps the
1081 // through either flip, and an animated track would fight it frame by frame. The1475 // content pinned would fight an animated track frame by frame. Reopening happens
1082 // top row's crossfade is the transition.1476 // at the top with no compensation, so the fold can ease back in instead of
1083 condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]",1477 // snapping the content down.
1478 condensed
1479 ? "grid-rows-[0fr]"
1480 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
1084 )}1481 )}
1085 >1482 >
1086 <div1483 <div
...@@ -1098,7 +1495,66 @@ export function PullRequestDetailPanel({...@@ -1098,7 +1495,66 @@ export function PullRequestDetailPanel({
1098 >1495 >
1099 {detail ? (1496 {detail ? (
1100 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">1497 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">
1101 <h1 className="text-base font-semibold leading-snug">{detail.title}</h1>1498 {titleDraft === null ? (
1499 <div className="group flex min-w-0 items-start gap-1">
1500 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
1501 {detail.title}
1502 </h1>
1503 {canEditPullRequestChangeRequest(detail) ? (
1504 <Button
1505 size="icon-xs"
1506 variant="ghost"
1507 className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
1508 aria-label="Edit title"
1509 onClick={() => setTitleScope({ pullRequestKey, text: detail.title })}
1510 >
1511 <PencilIcon className="size-3" />
1512 </Button>
1513 ) : null}
1514 </div>
1515 ) : (
1516 // A title is one line of text, not markdown, so it takes an input rather than
1517 // the editor the description and the remarks share.
1518 <div className="space-y-2">
1519 <Input
1520 autoFocus
1521 size="sm"
1522 disabled={titleSaving}
1523 value={titleDraft}
1524 aria-label="Pull request title"
1525 onChange={(event) =>
1526 setTitleScope({ pullRequestKey, text: event.target.value })
1527 }
1528 onKeyDown={(event) => {
1529 if (event.key === "Enter") {
1530 event.preventDefault();
1531 void saveTitle(titleDraft);
1532 } else if (event.key === "Escape") {
1533 event.preventDefault();
1534 setTitleScope(null);
1535 }
1536 }}
1537 />
1538 <div className="flex justify-end gap-2">
1539 <Button
1540 size="xs"
1541 variant="ghost"
1542 disabled={titleSaving}
1543 onClick={() => setTitleScope(null)}
1544 >
1545 Cancel
1546 </Button>
1547 <Button
1548 size="xs"
1549 variant="outline"
1550 disabled={titleSaving || titleDraft.trim().length === 0}
1551 onClick={() => void saveTitle(titleDraft)}
1552 >
1553 {titleSaving ? "Saving..." : "Save"}
1554 </Button>
1555 </div>
1556 </div>
1557 )}
1102 <PullRequestMetaLine className="mt-2 text-xs text-muted-foreground">1558 <PullRequestMetaLine className="mt-2 text-xs text-muted-foreground">
1103 <PullRequestActorLabel actor={detail.author} className="font-medium" />1559 <PullRequestActorLabel actor={detail.author} className="font-medium" />
1104 <span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>1560 <span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>
...@@ -1111,6 +1567,14 @@ export function PullRequestDetailPanel({...@@ -1111,6 +1567,14 @@ export function PullRequestDetailPanel({
1111 >1567 >
1112 {detail.baseBranch}1568 {detail.baseBranch}
1113 </code>1569 </code>
1570 {freshness ? (
1571 <PullRequestBaseFreshnessWarning
1572 baseBranch={detail.baseBranch}
1573 freshness={freshness}
1574 pending={actionPending}
1575 onUpdate={(method) => void perform("update-branch", undefined, method)}
1576 />
1577 ) : null}
1114 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />1578 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1115 <button1579 <button
1116 type="button"1580 type="button"
...@@ -1170,7 +1634,7 @@ export function PullRequestDetailPanel({...@@ -1170,7 +1634,7 @@ export function PullRequestDetailPanel({
1170 disabled={handoff !== null}1634 disabled={handoff !== null}
1171 onClick={startResolveConflicts}1635 onClick={startResolveConflicts}
1172 >1636 >
1173 {handoff === "conflicts" ? "Preparing..." : "Resolve in a new thread"}1637 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}
1174 <ArrowUpRightIcon className="size-3.5 text-destructive" />1638 <ArrowUpRightIcon className="size-3.5 text-destructive" />
1175 </Button>1639 </Button>
1176 </div>1640 </div>
...@@ -1178,7 +1642,7 @@ export function PullRequestDetailPanel({...@@ -1178,7 +1642,7 @@ export function PullRequestDetailPanel({
11781642
1179 {detail ? (1643 {detail ? (
1180 <nav1644 <nav
1181 className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2"1645 className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1182 aria-label="Pull request tabs"1646 aria-label="Pull request tabs"
1183 >1647 >
1184 {visibleTabs.map((item) => (1648 {visibleTabs.map((item) => (
...@@ -1202,7 +1666,13 @@ export function PullRequestDetailPanel({...@@ -1202,7 +1666,13 @@ export function PullRequestDetailPanel({
1202 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"1666 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1203 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}1667 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1204 >1668 >
1205 <CircleDotIcon aria-hidden className="size-3.5" />1669 {/* The rollup icon opens the checks behind the summary; with none reported
1670 there is nothing to open, so the plain glyph stays. */}
1671 {detail && checksState !== null ? (
1672 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1673 ) : (
1674 <CircleDotIcon aria-hidden className="size-3.5" />
1675 )}
1206 {checksSummary}1676 {checksSummary}
1207 </span>1677 </span>
1208 ) : tab === "timeline" ? (1678 ) : tab === "timeline" ? (
...@@ -1290,11 +1760,10 @@ export function PullRequestDetailPanel({...@@ -1290,11 +1760,10 @@ export function PullRequestDetailPanel({
1290 // scrollport actually gains is the difference between the two.1760 // scrollport actually gains is the difference between the two.
1291 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);1761 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
1292 if (previous) {1762 if (previous) {
1293 // The hard top reopens the chrome. The refund puts the reader a fold's height1763 // The hard top reopens the chrome with no refund: the reader asked for the top,
1294 // from the top, pinned to the same pixels — the metadata is scrolled up to,1764 // and moving them a fold's height back down would snatch it away — the fold
1295 // not thrown at them.1765 // slides in above while the content stays where they left it.
1296 if (top < 4 && foldHeight > 0) {1766 if (top < 4 && foldHeight > 0) {
1297 compensationRef.current = chromeDelta;
1298 next = false;1767 next = false;
1299 }1768 }
1300 } else if (foldHeight > 0 && top > foldHeight + 32) {1769 } else if (foldHeight > 0 && top > foldHeight + 32) {
...@@ -1329,6 +1798,8 @@ export function PullRequestDetailPanel({...@@ -1329,6 +1798,8 @@ export function PullRequestDetailPanel({
1329 activityPending={activityPending}1798 activityPending={activityPending}
1330 activityError={activityError}1799 activityError={activityError}
1331 pendingFinding={handoff}1800 pendingFinding={handoff}
1801 fixFindingLabel={handoffLabels.fixFinding}
1802 fixCheckLabel={handoffLabels.fixCheck}
1332 onFixFinding={startFixFinding}1803 onFixFinding={startFixFinding}
1333 onRefresh={refreshDetail}1804 onRefresh={refreshDetail}
1334 />1805 />
...@@ -1346,8 +1817,11 @@ export function PullRequestDetailPanel({...@@ -1346,8 +1817,11 @@ export function PullRequestDetailPanel({
1346 ) : (1817 ) : (
1347 <PullRequestTimelineTab1818 <PullRequestTimelineTab
1348 detail={detail}1819 detail={detail}
1820 environmentId={environmentId}
1821 reference={reference}
1349 order={timelineOrder}1822 order={timelineOrder}
1350 onOpenCommit={openCommit}1823 onOpenCommit={openCommit}
1824 onRefresh={refreshDetail}
1351 />1825 />
1352 )}1826 )}
1353 </div>1827 </div>
...@@ -1363,6 +1837,7 @@ export function PullRequestDetailPanel({...@@ -1363,6 +1837,7 @@ export function PullRequestDetailPanel({
1363 selectedCommitOid={selectedCodeCommitOid}1837 selectedCommitOid={selectedCodeCommitOid}
1364 onSelectedCommitChange={selectCodeCommit}1838 onSelectedCommitChange={selectCodeCommit}
1365 pendingFinding={handoff}1839 pendingFinding={handoff}
1840 fixFindingLabel={handoffLabels.fixFinding}
1366 onFixFinding={startFixFinding}1841 onFixFinding={startFixFinding}
1367 onRefresh={refreshDetail}1842 onRefresh={refreshDetail}
1368 refreshToken={refreshToken}1843 refreshToken={refreshToken}
...@@ -1381,12 +1856,21 @@ export function PullRequestDetailPanel({...@@ -1381,12 +1856,21 @@ export function PullRequestDetailPanel({
1381 <AlertDialogPopup>1856 <AlertDialogPopup>
1382 <AlertDialogHeader>1857 <AlertDialogHeader>
1383 <AlertDialogTitle>1858 <AlertDialogTitle>
1384 {confirmAction === "merge" ? "Merge pull request?" : "Close pull request?"}1859 {confirmAction === "merge"
1860 ? "Merge pull request?"
1861 : confirmAction === "enable-auto-merge"
1862 ? "Enable auto-merge?"
1863 : "Close pull request?"}
1385 </AlertDialogTitle>1864 </AlertDialogTitle>
1386 <AlertDialogDescription>1865 <AlertDialogDescription>
1387 {confirmAction === "merge"1866 {confirmAction === "merge"
1388 ? `This merges #${reference.number} using ${selectedMergeMethod}.`1867 ? `This merges #${reference.number} using ${selectedMergeMethod}.`
1389 : `This closes #${reference.number} without merging it.`}1868 : confirmAction === "enable-auto-merge"
1869 ? // The host merges this as soon as it considers the pull request ready, which
1870 // may be immediately — there is no telling from here whether anything is
1871 // still outstanding.
1872 `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.`
1873 : `This closes #${reference.number} without merging it.`}
1390 </AlertDialogDescription>1874 </AlertDialogDescription>
1391 </AlertDialogHeader>1875 </AlertDialogHeader>
1392 <AlertDialogFooter>1876 <AlertDialogFooter>
...@@ -1401,10 +1885,16 @@ export function PullRequestDetailPanel({...@@ -1401,10 +1885,16 @@ export function PullRequestDetailPanel({
1401 const action = confirmAction;1885 const action = confirmAction;
1402 setConfirmAction(null);1886 setConfirmAction(null);
1403 if (action === "merge") void perform("merge", selectedMergeMethod);1887 if (action === "merge") void perform("merge", selectedMergeMethod);
1888 if (action === "enable-auto-merge")
1889 void perform("enable-auto-merge", selectedMergeMethod);
1404 if (action === "close") void perform("close");1890 if (action === "close") void perform("close");
1405 }}1891 }}
1406 >1892 >
1407 {confirmAction === "merge" ? "Merge" : "Close"}1893 {confirmAction === "merge"
1894 ? "Merge"
1895 : confirmAction === "enable-auto-merge"
1896 ? "Enable auto-merge"
1897 : "Close"}
1408 </Button>1898 </Button>
1409 </AlertDialogFooter>1899 </AlertDialogFooter>
1410 </AlertDialogPopup>1900 </AlertDialogPopup>
apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx+71-5
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ProjectId } from "@t3tools/contracts";1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { CircleIcon } from "lucide-react";2import { CircleIcon } from "lucide-react";
3import { Children, isValidElement, type ReactElement, type ReactNode } from "react";3import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
4import { describe, expect, it, vi } from "vite-plus/test";4import { describe, expect, it, vi } from "vite-plus/test";
...@@ -53,12 +53,17 @@ function menu(overrides: Partial<Parameters<typeof PullRequestFiltersMenu>[0]>)...@@ -53,12 +53,17 @@ function menu(overrides: Partial<Parameters<typeof PullRequestFiltersMenu>[0]>)
53 involvement: "all",53 involvement: "all",
54 involvementOptions: [{ value: "all", label: "All", Icon: CircleIcon }],54 involvementOptions: [{ value: "all", label: "All", Icon: CircleIcon }],
55 onInvolvement: () => undefined,55 onInvolvement: () => undefined,
56 filters: {},
57 onFilters: () => undefined,
56 host: undefined,58 host: undefined,
57 hostOptions: [],59 hostOptions: [],
58 onHost: () => undefined,60 onHost: () => undefined,
59 environmentId: null,61 server: undefined,
62 serverOptions: [],
63 onServer: () => undefined,
60 projects: [],64 projects: [],
61 projectId: undefined,65 projectId: undefined,
66 projectEnvironmentId: undefined,
62 unavailable: new Map(),67 unavailable: new Map(),
63 onProject: () => undefined,68 onProject: () => undefined,
64 ...overrides,69 ...overrides,
...@@ -79,21 +84,82 @@ describe("pull request filters menu", () => {...@@ -79,21 +84,82 @@ describe("pull request filters menu", () => {
79 expect(onState).toHaveBeenCalledWith("closed");84 expect(onState).toHaveBeenCalledWith("closed");
80 });85 });
8186
87 it("names the chosen narrowing and leaves the others alone", () => {
88 const onFilters = vi.fn();
89 const group = findValueChange(
90 findLabeledGroup(menu({ filters: { review: "approved" }, onFilters }), "Draft"),
91 );
92 expect(group).toBeDefined();
93
94 group?.props.onValueChange("hide");
95 expect(onFilters).toHaveBeenCalledWith({ review: "approved", draft: "hide" });
96 });
97
98 it("drops a narrowing chosen back to all rather than sending it as undefined", () => {
99 const onFilters = vi.fn();
100 const group = findValueChange(
101 findLabeledGroup(
102 menu({ filters: { review: "none", checks: "failing" }, onFilters }),
103 "Review",
104 ),
105 );
106 expect(group).toBeDefined();
107
108 group?.props.onValueChange("all");
109 expect(onFilters).toHaveBeenCalledWith({ checks: "failing" });
110 });
111
82 it("does not emit a change when the selected project is chosen again", () => {112 it("does not emit a change when the selected project is chosen again", () => {
83 const projectId = "project-1" as ProjectId;113 const projectId = "project-1" as ProjectId;
114 const environmentId = "env-1" as EnvironmentId;
84 const onProject = vi.fn();115 const onProject = vi.fn();
85 const view = menu({116 const view = menu({
86 projects: [{ id: projectId, title: "T3 Code", workspaceRoot: "/work/t3code" }],117 projects: [
118 {
119 id: projectId,
120 environmentId,
121 title: "T3 Code",
122 workspaceRoot: "/work/t3code",
123 },
124 ],
87 projectId,125 projectId,
126 projectEnvironmentId: environmentId,
88 onProject,127 onProject,
89 });128 });
90 const radioGroup = findValueChange(view);129 const radioGroup = findValueChange(view);
91 expect(radioGroup).toBeDefined();130 expect(radioGroup).toBeDefined();
92131
93 radioGroup?.props.onValueChange(projectId);132 radioGroup?.props.onValueChange(`${environmentId} ${projectId}`);
94 expect(onProject).not.toHaveBeenCalled();133 expect(onProject).not.toHaveBeenCalled();
95134
96 radioGroup?.props.onValueChange("all");135 radioGroup?.props.onValueChange("all");
97 expect(onProject).toHaveBeenCalledWith(undefined);136 expect(onProject).toHaveBeenCalledWith(undefined, undefined);
137 });
138
139 it("passes the environment along so a duplicate project id on another server is told apart", () => {
140 const projectId = "project-1" as ProjectId;
141 const onProject = vi.fn();
142 const view = menu({
143 projects: [
144 {
145 id: projectId,
146 environmentId: "env-1" as EnvironmentId,
147 title: "T3 Code · one",
148 workspaceRoot: "/work/t3code-1",
149 },
150 {
151 id: projectId,
152 environmentId: "env-2" as EnvironmentId,
153 title: "T3 Code · two",
154 workspaceRoot: "/work/t3code-2",
155 },
156 ],
157 onProject,
158 });
159 const radioGroup = findValueChange(view);
160 expect(radioGroup).toBeDefined();
161
162 radioGroup?.props.onValueChange(`env-2 ${projectId}`);
163 expect(onProject).toHaveBeenCalledWith(projectId, "env-2");
98 });164 });
99});165});
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+148-22
...@@ -2,10 +2,23 @@ import type {...@@ -2,10 +2,23 @@ import type {
2 EnvironmentId,2 EnvironmentId,
3 ProjectId,3 ProjectId,
4 PullRequestInvolvement,4 PullRequestInvolvement,
5 PullRequestListFilters,
5 PullRequestListState,6 PullRequestListState,
6 SourceControlProviderKind,7 SourceControlProviderKind,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { FolderGit2Icon, LayersIcon, ListFilterIcon, LoaderIcon, SearchIcon } from "lucide-react";9import {
10 CircleCheckIcon,
11 CircleDashedIcon,
12 CircleSlashIcon,
13 CircleXIcon,
14 EyeOffIcon,
15 FolderGit2Icon,
16 GitPullRequestDraftIcon,
17 LayersIcon,
18 ListFilterIcon,
19 LoaderIcon,
20 SearchIcon,
21} from "lucide-react";
9import type { ElementType } from "react";22import type { ElementType } from "react";
1023
11import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
...@@ -82,7 +95,7 @@ export function PullRequestSearchInput({...@@ -82,7 +95,7 @@ export function PullRequestSearchInput({
82 type="text"95 type="text"
83 value={value}96 value={value}
84 onChange={(event) => onChange(event.currentTarget.value)}97 onChange={(event) => onChange(event.currentTarget.value)}
85 placeholder="Search pull requests"98 placeholder="Search pull requests, or label:bug"
86 aria-label="Search pull requests"99 aria-label="Search pull requests"
87 // Tracks the shared input's height at both widths, so it stays level with the icon100 // Tracks the shared input's height at both widths, so it stays level with the icon
88 // button beside it rather than towering over it on wide screens.101 // button beside it rather than towering over it on wide screens.
...@@ -101,6 +114,38 @@ export function PullRequestSearchInput({...@@ -101,6 +114,38 @@ export function PullRequestSearchInput({
101const ALL_PROJECTS_VALUE = "all";114const ALL_PROJECTS_VALUE = "all";
102/** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */115/** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */
103const ALL_HOSTS_VALUE = "";116const ALL_HOSTS_VALUE = "";
117/** The same trick for the servers, which are named by an id no empty string can collide with. */
118const ALL_SERVERS_VALUE = "";
119/** The unset value of each narrowing group, which no filter of theirs is named after. */
120const UNFILTERED_VALUE = "all";
121/**
122 * A project's own radio value, carrying the server along with the id: the id alone is only
123 * unique within its own server, so two rows sharing one would otherwise both read as checked.
124 */
125const projectMenuValue = (project: {
126 readonly id: ProjectId;
127 readonly environmentId: EnvironmentId;
128}) => `${project.environmentId} ${project.id}`;
129
130const DRAFT_OPTIONS = [
131 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
132 { value: "only", label: "Drafts only", Icon: GitPullRequestDraftIcon },
133 { value: "hide", label: "Hide drafts", Icon: EyeOffIcon },
134] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;
135
136const REVIEW_OPTIONS = [
137 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
138 { value: "approved", label: "Approved", Icon: CircleCheckIcon },
139 { value: "changes-requested", label: "Changes requested", Icon: CircleXIcon },
140 { value: "review-required", label: "Review required", Icon: CircleDashedIcon },
141 { value: "none", label: "No reviews", Icon: CircleSlashIcon },
142] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;
143
144const CHECKS_OPTIONS = [
145 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
146 { value: "passing", label: "Passing", Icon: CircleCheckIcon },
147 { value: "failing", label: "Failing", Icon: CircleXIcon },
148] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;
104149
105function PullRequestFilterRadioGroup<Value extends string>({150function PullRequestFilterRadioGroup<Value extends string>({
106 label,151 label,
...@@ -147,12 +192,17 @@ export function PullRequestFiltersMenu({...@@ -147,12 +192,17 @@ export function PullRequestFiltersMenu({
147 involvement,192 involvement,
148 involvementOptions,193 involvementOptions,
149 onInvolvement,194 onInvolvement,
195 filters,
196 onFilters,
150 host,197 host,
151 hostOptions,198 hostOptions,
152 onHost,199 onHost,
153 environmentId,200 server,
201 serverOptions,
202 onServer,
154 projects,203 projects,
155 projectId,204 projectId,
205 projectEnvironmentId,
156 unavailable,206 unavailable,
157 onProject,207 onProject,
158}: {208}: {
...@@ -162,6 +212,9 @@ export function PullRequestFiltersMenu({...@@ -162,6 +212,9 @@ export function PullRequestFiltersMenu({
162 involvement: PullRequestInvolvement;212 involvement: PullRequestInvolvement;
163 involvementOptions: ReadonlyArray<PullRequestFilterOption<PullRequestInvolvement>>;213 involvementOptions: ReadonlyArray<PullRequestFilterOption<PullRequestInvolvement>>;
164 onInvolvement: (involvement: PullRequestInvolvement) => void;214 onInvolvement: (involvement: PullRequestInvolvement) => void;
215 /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */
216 filters: PullRequestListFilters;
217 onFilters: (filters: PullRequestListFilters) => void;
165 host: string | undefined;218 host: string | undefined;
166 /**219 /**
167 * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real220 * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real
...@@ -169,24 +222,52 @@ export function PullRequestFiltersMenu({...@@ -169,24 +222,52 @@ export function PullRequestFiltersMenu({
169 */222 */
170 hostOptions: ReadonlyArray<PullRequestFilterOption<string>>;223 hostOptions: ReadonlyArray<PullRequestFilterOption<string>>;
171 onHost: (host: string | undefined) => void;224 onHost: (host: string | undefined) => void;
172 /** Where the projects' own favicons are read from; null before the environment is known. */225 server: EnvironmentId | undefined;
173 environmentId: EnvironmentId | null;226 /**
227 * Includes the "all servers" entry, whose value is the empty string. With one server there is
228 * nothing to switch between, so the whole group stays out of the menu.
229 */
230 serverOptions: ReadonlyArray<PullRequestFilterOption<string>>;
231 onServer: (server: EnvironmentId | undefined) => void;
232 /** The projects of every connected environment, each carrying the one its favicon is read from. */
174 projects: ReadonlyArray<{233 projects: ReadonlyArray<{
175 readonly id: ProjectId;234 readonly id: ProjectId;
235 readonly environmentId: EnvironmentId;
176 readonly title: string;236 readonly title: string;
177 readonly workspaceRoot: string;237 readonly workspaceRoot: string;
178 }>;238 }>;
179 projectId: ProjectId | undefined;239 projectId: ProjectId | undefined;
240 /**
241 * The server the selected project belongs to. A project id is only unique within its own
242 * server, so without this two rows sharing an id would both read as checked here.
243 */
244 projectEnvironmentId: EnvironmentId | undefined;
180 /**245 /**
181 * Projects whose repository could not be read this time round. They are named here, where246 * Projects whose repository could not be read this time round. They are named here, where
182 * the reader is already choosing between projects, rather than as a count above the list247 * the reader is already choosing between projects, rather than as a count above the list
183 * that says something is missing without saying which.248 * that says something is missing without saying which.
184 */249 */
185 unavailable: ReadonlyMap<ProjectId, string>;250 unavailable: ReadonlyMap<ProjectId, string>;
186 onProject: (projectId: ProjectId | undefined) => void;251 /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */
252 onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void;
187}) {253}) {
188 const filtered =254 const filtered =
189 state !== "open" || involvement !== "all" || host !== undefined || projectId !== undefined;255 state !== "open" ||
256 involvement !== "all" ||
257 host !== undefined ||
258 server !== undefined ||
259 projectId !== undefined ||
260 Object.keys(filters).length > 0;
261 /**
262 * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in
263 * it as an explicit `undefined`, which the listing input does not accept.
264 */
265 const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters =>
266 Object.fromEntries(
267 Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter(
268 ([, held]) => held !== undefined,
269 ),
270 ) as PullRequestListFilters;
190 return (271 return (
191 <Menu>272 <Menu>
192 <MenuTrigger273 <MenuTrigger
...@@ -219,6 +300,27 @@ export function PullRequestFiltersMenu({...@@ -219,6 +300,27 @@ export function PullRequestFiltersMenu({
219 options={involvementOptions}300 options={involvementOptions}
220 onChange={onInvolvement}301 onChange={onInvolvement}
221 />302 />
303 <MenuSeparator />
304 <PullRequestFilterRadioGroup
305 label="Draft"
306 value={filters.draft ?? UNFILTERED_VALUE}
307 options={DRAFT_OPTIONS}
308 onChange={(next) => onFilters(withFilter("draft", next))}
309 />
310 <MenuSeparator />
311 <PullRequestFilterRadioGroup
312 label="Review"
313 value={filters.review ?? UNFILTERED_VALUE}
314 options={REVIEW_OPTIONS}
315 onChange={(next) => onFilters(withFilter("review", next))}
316 />
317 <MenuSeparator />
318 <PullRequestFilterRadioGroup
319 label="Checks"
320 value={filters.checks ?? UNFILTERED_VALUE}
321 options={CHECKS_OPTIONS}
322 onChange={(next) => onFilters(withFilter("checks", next))}
323 />
222 {hostOptions.length > 2 ? (324 {hostOptions.length > 2 ? (
223 <>325 <>
224 <MenuSeparator />326 <MenuSeparator />
...@@ -230,12 +332,40 @@ export function PullRequestFiltersMenu({...@@ -230,12 +332,40 @@ export function PullRequestFiltersMenu({
230 />332 />
231 </>333 </>
232 ) : null}334 ) : null}
335 {serverOptions.length > 2 ? (
336 <>
337 <MenuSeparator />
338 <PullRequestFilterRadioGroup
339 label="Server"
340 value={server ?? ALL_SERVERS_VALUE}
341 options={serverOptions}
342 onChange={(next) =>
343 onServer(next === ALL_SERVERS_VALUE ? undefined : (next as EnvironmentId))
344 }
345 />
346 </>
347 ) : null}
233 <MenuSeparator />348 <MenuSeparator />
234 <MenuRadioGroup349 <MenuRadioGroup
235 value={projectId ?? ALL_PROJECTS_VALUE}350 value={
351 projectId === undefined || projectEnvironmentId === undefined
352 ? ALL_PROJECTS_VALUE
353 : projectMenuValue({ id: projectId, environmentId: projectEnvironmentId })
354 }
236 onValueChange={(next) => {355 onValueChange={(next) => {
237 const nextProjectId = next === ALL_PROJECTS_VALUE ? undefined : (next as ProjectId);356 if (next === ALL_PROJECTS_VALUE) {
238 if (nextProjectId !== projectId) onProject(nextProjectId);357 if (projectId !== undefined) onProject(undefined, undefined);
358 return;
359 }
360 // The value carries both halves, since the id alone cannot tell two servers' rows
361 // apart once they share one.
362 const project = projects.find((candidate) => projectMenuValue(candidate) === next);
363 if (
364 project !== undefined &&
365 (project.id !== projectId || project.environmentId !== projectEnvironmentId)
366 ) {
367 onProject(project.id, project.environmentId);
368 }
239 }}369 }}
240 >370 >
241 <MenuGroupLabel>Project</MenuGroupLabel>371 <MenuGroupLabel>Project</MenuGroupLabel>
...@@ -255,22 +385,18 @@ export function PullRequestFiltersMenu({...@@ -255,22 +385,18 @@ export function PullRequestFiltersMenu({
255 const reason = unavailable.get(project.id);385 const reason = unavailable.get(project.id);
256 return (386 return (
257 <MenuRadioItem387 <MenuRadioItem
258 key={project.id}388 key={projectMenuValue(project)}
259 value={project.id}389 value={projectMenuValue(project)}
260 disabled={reason !== undefined}390 disabled={reason !== undefined}
261 title={reason}391 title={reason}
262 >392 >
263 <span className="flex min-w-0 flex-1 items-center gap-2">393 <span className="flex min-w-0 flex-1 items-center gap-2">
264 {environmentId === null ? (394 <ProjectFavicon
265 <FolderGit2Icon aria-hidden className="size-3.5 shrink-0" />395 environmentId={project.environmentId}
266 ) : (396 cwd={project.workspaceRoot}
267 <ProjectFavicon397 fallbackIcon={FolderGit2Icon}
268 environmentId={environmentId}398 className="size-3.5 shrink-0"
269 cwd={project.workspaceRoot}399 />
270 fallbackIcon={FolderGit2Icon}
271 className="size-3.5 shrink-0"
272 />
273 )}
274 <span className="min-w-0 flex-1 truncate">{project.title}</span>400 <span className="min-w-0 flex-1 truncate">{project.title}</span>
275 {reason === undefined ? null : (401 {reason === undefined ? null : (
276 <span className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-px text-[10px] font-medium text-amber-600 dark:text-amber-400/90">402 <span className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-px text-[10px] font-medium text-amber-600 dark:text-amber-400/90">
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx created+113
...@@ -0,0 +1,113 @@
1import { useState } from "react";
2
3import { cn } from "~/lib/utils";
4
5import { Button } from "../ui/button";
6import { Textarea } from "../ui/textarea";
7import { PullRequestMarkdown } from "./PullRequestMarkdown";
8
9/**
10 * The box a body is rewritten in — a description, or a remark already posted. It owns the draft
11 * and nothing else: the caller sends the request and says whether it is still in flight, so the
12 * same box serves every mutation without knowing which one it is.
13 *
14 * Preview renders through the same component the saved body will be read through, which is the
15 * only way to see what a host's markdown will actually become before it is sent.
16 */
17export function PullRequestMarkdownEditor({
18 value,
19 cwd,
20 placeholder,
21 label,
22 saving,
23 allowEmpty = false,
24 className,
25 onSave,
26 onCancel,
27}: {
28 readonly value: string;
29 readonly cwd: string;
30 readonly placeholder?: string | undefined;
31 readonly label: string;
32 readonly saving: boolean;
33 /** A description may be cleared, which is how one is removed; a remark may not be emptied. */
34 readonly allowEmpty?: boolean;
35 readonly className?: string | undefined;
36 readonly onSave: (next: string) => void;
37 readonly onCancel: () => void;
38}) {
39 const [draft, setDraft] = useState(value);
40 const [preview, setPreview] = useState(false);
41 // The words this draft started from. React keeps a component instance wherever the same
42 // position and key come round again, so an editor opened on one remark can be handed another's
43 // words without being rebuilt — and saving would then write the first remark's text onto the
44 // second. Different words mean a different subject, and the draft starts again from them.
45 const [seed, setSeed] = useState(value);
46 if (seed !== value) {
47 setSeed(value);
48 setDraft(value);
49 }
50 const empty = draft.trim().length === 0;
51
52 return (
53 <div
54 className={cn("space-y-2", className)}
55 onKeyDown={(event) => {
56 if (event.key !== "Escape" || saving) return;
57 event.preventDefault();
58 onCancel();
59 }}
60 >
61 <div className="flex items-center gap-1">
62 <Button
63 size="xs"
64 variant={preview ? "ghost" : "outline"}
65 disabled={saving}
66 onClick={() => setPreview(false)}
67 >
68 Write
69 </Button>
70 <Button
71 size="xs"
72 variant={preview ? "outline" : "ghost"}
73 disabled={saving}
74 onClick={() => setPreview(true)}
75 >
76 Preview
77 </Button>
78 </div>
79 {preview ? (
80 <div className="rounded-lg border border-border/60 px-3 py-2">
81 {empty ? (
82 <p className="text-xs text-muted-foreground">Nothing to preview.</p>
83 ) : (
84 <PullRequestMarkdown text={draft} cwd={cwd} />
85 )}
86 </div>
87 ) : (
88 <Textarea
89 autoFocus
90 disabled={saving}
91 value={draft}
92 rows={6}
93 placeholder={placeholder}
94 aria-label={label}
95 onChange={(event) => setDraft(event.target.value)}
96 />
97 )}
98 <div className="flex justify-end gap-2">
99 <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}>
100 Cancel
101 </Button>
102 <Button
103 size="xs"
104 variant="outline"
105 disabled={saving || (empty && !allowEmpty)}
106 onClick={() => onSave(draft)}
107 >
108 {saving ? "Saving..." : "Save"}
109 </Button>
110 </div>
111 </div>
112 );
113}
apps/web/src/components/pullRequest/PullRequestReactions.tsx created+178
...@@ -0,0 +1,178 @@
1import type {
2 EnvironmentId,
3 PullRequestReaction,
4 PullRequestReactionContent,
5 PullRequestRef,
6} from "@t3tools/contracts";
7import { SmilePlusIcon } from "lucide-react";
8import { useState } from "react";
9
10import { cn } from "~/lib/utils";
11import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useAtomCommand } from "~/state/use-atom-command";
13
14import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
15import { toastManager } from "../ui/toast";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import {
18 applyPendingPullRequestReactions,
19 PULL_REQUEST_REACTION_ORDER,
20 pullRequestReactionEmoji,
21 pullRequestReactionName,
22 pullRequestReactionTooltip,
23} from "./pullRequestReactions.logic";
24
25const PILL_CLASS =
26 "inline-flex h-6 shrink-0 items-center gap-1 rounded-full border px-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
27
28const EMPTY_PENDING: ReadonlyMap<PullRequestReactionContent, boolean> = new Map();
29
30/** What the host last said, so a press in flight is forgotten the moment the real counts land. */
31function reactionsSignature(reactions: ReadonlyArray<PullRequestReaction>): string {
32 return reactions
33 .map((reaction) => `${reaction.content}:${reaction.count}:${reaction.viewerHasReacted ? 1 : 0}`)
34 .join(" ");
35}
36
37/**
38 * The reaction pills under a remark, and the picker that adds one. The same bar serves the
39 * description, a conversation comment and a review thread's comments: what differs between them
40 * is only which subject the host is told about.
41 *
42 * The add button is revealed by hovering the remark it belongs to, the way GitHub's is, so the
43 * parent must carry `group`. It stays put once there is something to press it beside, while the
44 * picker is open, and whenever it is focused — a control only a mouse can find is no control.
45 */
46export function PullRequestReactionBar({
47 reactions,
48 canReact,
49 subjectId,
50 environmentId,
51 reference,
52 onRefresh,
53 className,
54}: {
55 readonly reactions: ReadonlyArray<PullRequestReaction>;
56 readonly canReact: boolean;
57 /** Absent reacts to the change request itself, which is where its description's reactions live. */
58 readonly subjectId?: string | undefined;
59 readonly environmentId: EnvironmentId;
60 readonly reference: PullRequestRef;
61 readonly onRefresh: () => void;
62 readonly className?: string | undefined;
63}) {
64 const [pickerOpen, setPickerOpen] = useState(false);
65 const [pending, setPending] = useState<{
66 readonly signature: string;
67 readonly values: ReadonlyMap<PullRequestReactionContent, boolean>;
68 }>({ signature: "", values: EMPTY_PENDING });
69 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false });
70
71 const signature = reactionsSignature(reactions);
72 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;
73 const shown = applyPendingPullRequestReactions(reactions, values);
74
75 const toggle = async (content: PullRequestReactionContent, reacted: boolean) => {
76 setPending({ signature, values: new Map([...values, [content, reacted]]) });
77 const result = await setReaction({
78 environmentId,
79 input: {
80 ...reference,
81 ...(subjectId === undefined ? {} : { subjectId }),
82 content,
83 reacted,
84 },
85 });
86 if (result._tag === "Failure") {
87 setPending((current) => {
88 const next = new Map(current.values);
89 next.delete(content);
90 return { signature: current.signature, values: next };
91 });
92 toastManager.add({ type: "error", title: "The reaction could not be saved" });
93 return;
94 }
95 onRefresh();
96 };
97
98 if (shown.length === 0 && !canReact) return null;
99
100 return (
101 <div className={cn("flex flex-wrap items-center gap-1", className)}>
102 {shown.map((reaction) => (
103 <Tooltip key={reaction.content}>
104 <TooltipTrigger
105 render={
106 <button
107 type="button"
108 aria-pressed={reaction.viewerHasReacted}
109 aria-label={`${pullRequestReactionName(reaction.content)}, ${reaction.count}`}
110 disabled={!canReact}
111 className={cn(
112 PILL_CLASS,
113 reaction.viewerHasReacted
114 ? "border-primary/60 bg-primary/10 text-foreground"
115 : "border-border/70 bg-muted/40 text-muted-foreground",
116 canReact ? "hover:border-primary/60" : "cursor-default",
117 )}
118 onClick={() => void toggle(reaction.content, !reaction.viewerHasReacted)}
119 />
120 }
121 >
122 <span aria-hidden>{pullRequestReactionEmoji(reaction.content)}</span>
123 <span className="tabular-nums">{reaction.count}</span>
124 </TooltipTrigger>
125 <TooltipPopup side="top">{pullRequestReactionTooltip(reaction)}</TooltipPopup>
126 </Tooltip>
127 ))}
128
129 {canReact ? (
130 <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
131 <PopoverTrigger
132 render={
133 <button
134 type="button"
135 aria-label="Add a reaction"
136 className={cn(
137 PILL_CLASS,
138 "border-border/70 px-1.5 text-muted-foreground hover:border-primary/60 hover:text-foreground",
139 shown.length === 0 &&
140 !pickerOpen &&
141 "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100",
142 )}
143 />
144 }
145 >
146 <SmilePlusIcon aria-hidden className="size-3.5" />
147 </PopoverTrigger>
148 <PopoverPopup align="start" className="w-auto" side="top" viewportClassName="py-2">
149 <div className="flex items-center gap-0.5">
150 {PULL_REQUEST_REACTION_ORDER.map((content) => {
151 const reacted =
152 shown.find((reaction) => reaction.content === content)?.viewerHasReacted ?? false;
153 return (
154 <button
155 key={content}
156 type="button"
157 aria-pressed={reacted}
158 aria-label={pullRequestReactionName(content)}
159 className={cn(
160 "flex size-7 items-center justify-center rounded-md text-base outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
161 reacted && "bg-primary/10",
162 )}
163 onClick={() => {
164 setPickerOpen(false);
165 void toggle(content, !reacted);
166 }}
167 >
168 <span aria-hidden>{pullRequestReactionEmoji(content)}</span>
169 </button>
170 );
171 })}
172 </div>
173 </PopoverPopup>
174 </Popover>
175 ) : null}
176 </div>
177 );
178}
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+75-7
...@@ -2,12 +2,18 @@...@@ -2,12 +2,18 @@
2 * Pull-request-specific annotations: conversations already on the host and comments queued for2 * Pull-request-specific annotations: conversations already on the host and comments queued for
3 * the review being written. New comment composition uses the shared diff annotation.3 * the review being written. New comment composition uses the shared diff annotation.
4 */4 */
5import type { PullRequestReviewThread } from "@t3tools/contracts";5import type {
6 EnvironmentId,
7 PullRequestRef,
8 PullRequestReviewThread,
9 PullRequestThreadComment,
10} from "@t3tools/contracts";
6import {11import {
7 CheckCircle2Icon,12 CheckCircle2Icon,
8 CircleIcon,13 CircleIcon,
9 HammerIcon,14 HammerIcon,
10 MessageSquareIcon,15 MessageSquareIcon,
16 PencilIcon,
11 Trash2Icon,17 Trash2Icon,
12} from "lucide-react";18} from "lucide-react";
13import { useRef, useState } from "react";19import { useRef, useState } from "react";
...@@ -20,6 +26,8 @@ import { Textarea } from "../ui/textarea";...@@ -20,6 +26,8 @@ import { Textarea } from "../ui/textarea";
20import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";26import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";
21import { PullRequestActorLabel } from "./pullRequestPresentation";27import { PullRequestActorLabel } from "./pullRequestPresentation";
22import { PullRequestMarkdown } from "./PullRequestMarkdown";28import { PullRequestMarkdown } from "./PullRequestMarkdown";
29import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
30import { PullRequestReactionBar } from "./PullRequestReactions";
23import type { PendingReviewComment } from "./pullRequestReviewStore";31import type { PendingReviewComment } from "./pullRequestReviewStore";
2432
25const CARD_CLASS =33const CARD_CLASS =
...@@ -82,31 +90,57 @@ export function ReviewThreadCard({...@@ -82,31 +90,57 @@ export function ReviewThreadCard({
82 workspaceRoot,90 workspaceRoot,
83 canReply,91 canReply,
84 canResolve,92 canResolve,
93 canReact,
94 environmentId,
95 reference,
85 pending,96 pending,
86 fixPending,97 fixPending,
98 fixLabel = "Fix in a thread",
87 onFix,99 onFix,
88 onReply,100 onReply,
101 canEditComment,
102 onEditComment,
89 onToggleResolved,103 onToggleResolved,
104 onReacted,
90}: {105}: {
91 thread: PullRequestReviewThread;106 thread: PullRequestReviewThread;
92 workspaceRoot: string;107 workspaceRoot: string;
93 canReply: boolean;108 canReply: boolean;
94 canResolve: boolean;109 canResolve: boolean;
110 canReact: boolean;
111 environmentId: EnvironmentId;
112 reference: PullRequestRef;
95 pending: boolean;113 pending: boolean;
96 /** True while this thread's own hand-off is preparing, so only its button says so. */114 /** True while this thread's own hand-off is preparing, so only its button says so. */
97 fixPending?: boolean;115 fixPending?: boolean;
116 fixLabel?: string;
98 /** Absent where a thread is shown outside the pull request page's reach. */117 /** Absent where a thread is shown outside the pull request page's reach. */
99 onFix?: () => void;118 onFix?: () => void;
100 /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */119 /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */
101 onReply: (body: string) => Promise<boolean>;120 onReply: (body: string) => Promise<boolean>;
121 /** Whether this reader wrote this remark, which is what rewriting one takes. */
122 canEditComment: (comment: PullRequestThreadComment) => boolean;
123 /** Resolves to whether the host took it, like `onReply`. */
124 onEditComment: (commentId: string, body: string) => Promise<boolean>;
102 onToggleResolved: () => void;125 onToggleResolved: () => void;
126 onReacted: () => void;
103}) {127}) {
104 // A resolved thread is finished work, so it opens collapsed and stays one line until asked for.128 // A resolved thread is finished work, so it opens collapsed and stays one line until asked for.
105 const [expanded, setExpanded] = useState(!thread.isResolved);129 const [expanded, setExpanded] = useState(!thread.isResolved);
106 const [replying, setReplying] = useState(false);130 const [replying, setReplying] = useState(false);
107 const [reply, setReply] = useState("");131 const [reply, setReply] = useState("");
132 const [editingId, setEditingId] = useState<string | null>(null);
133 const [savingEdit, setSavingEdit] = useState(false);
108 const sendingRef = useRef(false);134 const sendingRef = useRef(false);
109135
136 const saveEdit = async (commentId: string, body: string) => {
137 if (savingEdit) return;
138 setSavingEdit(true);
139 const saved = await onEditComment(commentId, body);
140 setSavingEdit(false);
141 if (saved) setEditingId(null);
142 };
143
110 const send = async () => {144 const send = async () => {
111 const trimmed = reply.trim();145 const trimmed = reply.trim();
112 if (trimmed.length === 0 || pending || sendingRef.current) return;146 if (trimmed.length === 0 || pending || sendingRef.current) return;
...@@ -154,7 +188,7 @@ export function ReviewThreadCard({...@@ -154,7 +188,7 @@ export function ReviewThreadCard({
154 onClick={onFix}188 onClick={onFix}
155 >189 >
156 <HammerIcon className="size-3" />190 <HammerIcon className="size-3" />
157 {fixPending ? "Preparing..." : "Fix in a thread"}191 {fixPending ? "Preparing..." : fixLabel}
158 </Button>192 </Button>
159 ) : null}193 ) : null}
160 {canResolve ? (194 {canResolve ? (
...@@ -174,15 +208,49 @@ export function ReviewThreadCard({...@@ -174,15 +208,49 @@ export function ReviewThreadCard({
174 <>208 <>
175 <div className="mt-2 space-y-3">209 <div className="mt-2 space-y-3">
176 {thread.comments.map((comment) => (210 {thread.comments.map((comment) => (
177 <article key={comment.id} className="min-w-0">211 <article key={comment.id} className="group min-w-0">
178 <div className="flex items-center gap-2 text-xs text-muted-foreground">212 <div className="flex items-center gap-2 text-xs text-muted-foreground">
179 <PullRequestActorLabel actor={comment.author} className="text-foreground" />213 <PullRequestActorLabel actor={comment.author} className="text-foreground" />
180 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>214 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>
181 </div>215 </div>
182 <PullRequestMarkdown216 {editingId === comment.id ? (
183 className="mt-1 text-sm"217 <PullRequestMarkdownEditor
184 text={comment.body}218 className="mt-1"
185 cwd={workspaceRoot}219 value={comment.body}
220 cwd={workspaceRoot}
221 label="Edit comment"
222 saving={savingEdit}
223 onSave={(body) => void saveEdit(comment.id, body)}
224 onCancel={() => setEditingId(null)}
225 />
226 ) : (
227 <div className="mt-1 flex items-start gap-1">
228 <PullRequestMarkdown
229 className="min-w-0 flex-1 text-sm"
230 text={comment.body}
231 cwd={workspaceRoot}
232 />
233 {canEditComment(comment) ? (
234 <Button
235 size="icon-xs"
236 variant="ghost"
237 className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
238 aria-label="Edit comment"
239 onClick={() => setEditingId(comment.id)}
240 >
241 <PencilIcon className="size-3" />
242 </Button>
243 ) : null}
244 </div>
245 )}
246 <PullRequestReactionBar
247 className="mt-1.5"
248 reactions={comment.reactions ?? []}
249 canReact={canReact}
250 subjectId={comment.id}
251 environmentId={environmentId}
252 reference={reference}
253 onRefresh={onReacted}
186 />254 />
187 </article>255 </article>
188 ))}256 ))}
apps/web/src/components/pullRequest/PullRequestRow.tsx+51-8
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import type { PullRequestListEntry } from "@t3tools/contracts";
2
3import { memo } from "react";1import { memo } from "react";
42
5import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
...@@ -7,6 +5,9 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation...@@ -7,6 +5,9 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation
7import { formatRelativeTimeLabel } from "~/timestampFormat";5import { formatRelativeTimeLabel } from "~/timestampFormat";
86
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
9import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
10import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
10import {11import {
11 PullRequestActorLabel,12 PullRequestActorLabel,
12 PullRequestDiffStat,13 PullRequestDiffStat,
...@@ -19,20 +20,23 @@ function PullRequestRowImpl({...@@ -19,20 +20,23 @@ function PullRequestRowImpl({
19 selected,20 selected,
20 showProjectTitle,21 showProjectTitle,
21 showProvider,22 showProvider,
23 environmentLabel,
22 matchedElsewhere,24 matchedElsewhere,
23 onSelect,25 onSelect,
24}: {26}: {
25 entry: PullRequestListEntry;27 entry: EnvironmentPullRequestEntry;
26 selected: boolean;28 selected: boolean;
27 showProjectTitle: boolean;29 showProjectTitle: boolean;
28 /** Only when the list spans more than one host, where the repository alone is ambiguous. */30 /** Only when the list spans more than one host, where the repository alone is ambiguous. */
29 showProvider: boolean;31 showProvider: boolean;
32 /** Names the server this row was read from, where the list spans more than one. */
33 environmentLabel?: string;
30 /**34 /**
31 * A search found this, but in something the row does not show — a description, a comment, a35 * A search found this, but in something the row does not show — a description, a comment, a
32 * commit message. Saying so is the difference between a result and an apparently random row.36 * commit message. Saying so is the difference between a result and an apparently random row.
33 */37 */
34 matchedElsewhere?: boolean;38 matchedElsewhere?: boolean;
35 onSelect: (entry: PullRequestListEntry) => void;39 onSelect: (entry: EnvironmentPullRequestEntry) => void;
36}) {40}) {
37 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);41 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
38 return (42 return (
...@@ -67,13 +71,52 @@ function PullRequestRowImpl({...@@ -67,13 +71,52 @@ function PullRequestRowImpl({
67 <TooltipPopup>{providerName}</TooltipPopup>71 <TooltipPopup>{providerName}</TooltipPopup>
68 </Tooltip>72 </Tooltip>
69 ) : null}73 ) : null}
70 #{entry.number}74 {/* The number carries the link, here as much as on the detail: a right-click on it
75 copies the pull request's own address rather than opening the editing menu. */}
76 <span
77 onContextMenu={(event) => {
78 event.preventDefault();
79 event.stopPropagation();
80 void showPullRequestLinkContextMenu({
81 url: entry.url,
82 openLabel: openOnHostLabel(entry.provider),
83 position: { x: event.clientX, y: event.clientY },
84 });
85 }}
86 >
87 #{entry.number}
88 </span>
71 </span>89 </span>
72 {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null}90 {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null}
91 {environmentLabel ? (
92 <span className="max-w-32 shrink-0 truncate">{environmentLabel}</span>
93 ) : null}
73 <PullRequestActorLabel actor={entry.author} className="max-w-40 shrink-0" />94 <PullRequestActorLabel actor={entry.author} className="max-w-40 shrink-0" />
74 <span className="truncate" title={`${entry.headBranch} to ${entry.baseBranch}`}>95 {/* Only a verdict somebody has actually given: "review required" is the absence of
75 {entry.headBranch}96 one, and saying so on every unreviewed row would say nothing. */}
76 </span>97 {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? (
98 <span
99 className={cn(
100 "shrink-0",
101 entry.reviewDecision === "approved"
102 ? "text-emerald-600/90 dark:text-emerald-400/80"
103 : "text-amber-600/90 dark:text-amber-400/80",
104 )}
105 >
106 {entry.reviewDecision === "approved" ? "Approved" : "Changes requested"}
107 </span>
108 ) : null}
109 {entry.checksState === undefined ? null : (
110 <PullRequestChecksPopover
111 checksState={entry.checksState}
112 environmentId={entry.environmentId}
113 reference={{
114 projectId: entry.projectId,
115 repository: entry.repository,
116 number: entry.number,
117 }}
118 />
119 )}
77 {matchedElsewhere ? (120 {matchedElsewhere ? (
78 <span className="shrink-0 rounded-full border border-border/60 px-1.5 text-[10px]">121 <span className="shrink-0 rounded-full border border-border/60 px-1.5 text-[10px]">
79 matched in the description122 matched in the description
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+359-29
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 PullRequestActor,
4 PullRequestComment,
5 PullRequestDetailView,
6 PullRequestRef,
7} from "@t3tools/contracts";
2import {8import {
3 ArrowDownUpIcon,9 ArrowDownUpIcon,
10 ChevronDownIcon,
4 ChevronRightIcon,11 ChevronRightIcon,
5 HammerIcon,12 HammerIcon,
6 MessageSquareIcon,13 MessageSquareIcon,
14 PencilIcon,
7 SendIcon,15 SendIcon,
16 TagIcon,
8 UsersIcon,17 UsersIcon,
9} from "lucide-react";18} from "lucide-react";
10import { useState, type ReactNode } from "react";19import { useRef, useState, type ReactNode } from "react";
1120
12import { useAtomCommand } from "~/state/use-atom-command";21import { useAtomCommand } from "~/state/use-atom-command";
13import { pullRequestEnvironment } from "~/state/pullRequests";22import { pullRequestEnvironment } from "~/state/pullRequests";
...@@ -21,9 +30,9 @@ import { Textarea } from "../ui/textarea";...@@ -21,9 +30,9 @@ import { Textarea } from "../ui/textarea";
21import { toastManager } from "../ui/toast";30import { toastManager } from "../ui/toast";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";31import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import {32import {
33 PullRequestActorAvatar,
24 PullRequestActorLabel,34 PullRequestActorLabel,
25 PullRequestCheckStatusIcon,35 PullRequestCheckStatusIcon,
26 PullRequestMetaLine,
27 pullRequestCheckStatusLabel,36 pullRequestCheckStatusLabel,
28} from "./pullRequestPresentation";37} from "./pullRequestPresentation";
29import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";38import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";
...@@ -33,8 +42,149 @@ import {...@@ -33,8 +42,149 @@ import {
33 pullRequestFindingKey,42 pullRequestFindingKey,
34 type PullRequestFinding,43 type PullRequestFinding,
35} from "./pullRequestDetail.logic";44} from "./pullRequestDetail.logic";
45import {
46 canEditPullRequestChangeRequest,
47 canEditPullRequestComment,
48} from "./pullRequestEditing.logic";
36import { PullRequestMarkdown } from "./PullRequestMarkdown";49import { PullRequestMarkdown } from "./PullRequestMarkdown";
50import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
51import { PullRequestReactionBar } from "./PullRequestReactions";
37import { PullRequestConversationGhost } from "./PullRequestGhosts";52import { PullRequestConversationGhost } from "./PullRequestGhosts";
53import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";
54
55/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */
56function labelDotColor(color: string | null): string | null {
57 const hex = color?.trim().replace(/^#/, "") ?? "";
58 return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
59}
60
61/** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */
62function CommentAuthor({ actor }: { actor: PullRequestActor | null }) {
63 const login = actor?.login ?? "ghost";
64 return (
65 <Tooltip>
66 <TooltipTrigger render={<span className="shrink-0 rounded-full" aria-label={login} />}>
67 <PullRequestActorAvatar actor={actor} />
68 </TooltipTrigger>
69 <TooltipPopup side="bottom">
70 {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login}
71 </TooltipPopup>
72 </Tooltip>
73 );
74}
75
76/** "CHANGES_REQUESTED" reads as "Changes requested": one capital, the host's underscores gone. */
77function reviewStateLabel(state: string): string {
78 const words = state.toLowerCase().replace(/_/g, " ");
79 return words.charAt(0).toUpperCase() + words.slice(1);
80}
81
82/** What every remark in the conversation needs to be rewritten where it sits. */
83interface CommentEditing {
84 readonly cwd: string;
85 readonly canEdit: (comment: PullRequestComment) => boolean;
86 readonly editingId: string | null;
87 readonly saving: boolean;
88 readonly onEdit: (comment: PullRequestComment | null) => void;
89 readonly onSave: (comment: PullRequestComment, body: string) => void;
90}
91
92/**
93 * A remark's words, and the pencil that swaps them for the editor. The pencil is revealed by
94 * hovering the remark, like the reaction bar's own, so the parent must carry `group`.
95 */
96function CommentBody({
97 comment,
98 editing,
99 className,
100}: {
101 comment: PullRequestComment;
102 editing: CommentEditing;
103 className?: string | undefined;
104}) {
105 if (editing.editingId === comment.id) {
106 return (
107 <PullRequestMarkdownEditor
108 className={className}
109 value={comment.body}
110 cwd={editing.cwd}
111 label="Edit comment"
112 saving={editing.saving}
113 onSave={(body) => editing.onSave(comment, body)}
114 onCancel={() => editing.onEdit(null)}
115 />
116 );
117 }
118 return (
119 <div className={cn("flex items-start gap-1", className)}>
120 <PullRequestMarkdown className="min-w-0 flex-1" text={comment.body} cwd={editing.cwd} />
121 {editing.canEdit(comment) ? (
122 <Button
123 size="icon-xs"
124 variant="ghost"
125 className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
126 aria-label="Edit comment"
127 onClick={() => editing.onEdit(comment)}
128 >
129 <PencilIcon className="size-3" />
130 </Button>
131 ) : null}
132 </div>
133 );
134}
135
136/** Finished work — a resolved conversation or a dismissed approval — opens collapsed. */
137function CollapsedComment({
138 comment,
139 editing,
140 label,
141 reactionBar,
142}: {
143 comment: PullRequestComment;
144 editing: CommentEditing;
145 label: string;
146 reactionBar: ReactNode;
147}) {
148 const [open, setOpen] = useState(false);
149 return (
150 <Collapsible open={open} onOpenChange={setOpen}>
151 <article className="group rounded-lg border border-border/60 [contain-intrinsic-block-size:44px] [content-visibility:auto]">
152 <CollapsibleTrigger
153 className={cn(
154 "flex w-full items-center gap-2 p-3 text-left transition-opacity hover:opacity-100",
155 open ? "opacity-100" : "opacity-65",
156 )}
157 >
158 <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground">
159 <CommentAuthor actor={comment.author} />
160 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>
161 <span>{label}</span>
162 </span>
163 <ChevronDownIcon
164 aria-hidden
165 className={cn(
166 "size-3.5 shrink-0 text-muted-foreground transition-transform",
167 open && "rotate-180",
168 )}
169 />
170 </CollapsibleTrigger>
171 <CollapsiblePanel>
172 {open ? (
173 <div className="px-3 pb-3">
174 {comment.path ? (
175 <p className="truncate text-xs text-muted-foreground" title={comment.path}>
176 {comment.path}
177 </p>
178 ) : null}
179 <CommentBody className="mt-2" comment={comment} editing={editing} />
180 {reactionBar}
181 </div>
182 ) : null}
183 </CollapsiblePanel>
184 </article>
185 </Collapsible>
186 );
187}
38188
39function MetaRow({189function MetaRow({
40 icon,190 icon,
...@@ -72,9 +222,39 @@ function Section({...@@ -72,9 +222,39 @@ function Section({
72 children: ReactNode;222 children: ReactNode;
73}) {223}) {
74 const [open, setOpen] = useState(defaultOpen);224 const [open, setOpen] = useState(defaultOpen);
225 const headingRef = useRef<HTMLDivElement>(null);
226 const setOpenWithScrollAnchor = (nextOpen: boolean) => {
227 if (!nextOpen) {
228 const heading = headingRef.current;
229 const section = heading?.closest<HTMLElement>("[data-pull-request-summary-section]");
230 const scroller = heading?.closest<HTMLElement>("[data-pull-request-summary-scroll]");
231 if (heading && section && scroller) {
232 const target = sectionCollapseAnchorScrollTop({
233 scrollTop: scroller.scrollTop,
234 viewportTop: scroller.getBoundingClientRect().top,
235 sectionTop: section.getBoundingClientRect().top,
236 headingTop: heading.getBoundingClientRect().top,
237 });
238 // Synchronous with the press: React commits the collapsed height before the browser
239 // paints, so the reader sees the heading they pressed stay put rather than a jump first.
240 if (target !== null) scroller.scrollTop = target;
241 }
242 }
243 setOpen(nextOpen);
244 };
75 return (245 return (
76 <Collapsible open={open} onOpenChange={setOpen}>246 <Collapsible
77 <div className="flex w-full items-center border-t border-border/60 pr-4">247 open={open}
248 onOpenChange={setOpenWithScrollAnchor}
249 data-pull-request-summary-section
250 >
251 {/* The heading rides the top of the scroll box the way a diff's file header does, so a
252 section can be collapsed from wherever its body has been read to rather than only from
253 where it started. Opaque, because the rows it covers scroll beneath it. */}
254 <div
255 ref={headingRef}
256 className="sticky top-0 z-10 flex w-full items-center border-t border-border/60 bg-background pr-4"
257 >
78 {/* Title first, chevron riding to its right, count last: the row reads as a heading258 {/* Title first, chevron riding to its right, count last: the row reads as a heading
79 with an affordance rather than a tree node. */}259 with an affordance rather than a tree node. */}
80 <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-sm font-medium">260 <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-sm font-medium">
...@@ -174,6 +354,8 @@ export function PullRequestSummaryTab({...@@ -174,6 +354,8 @@ export function PullRequestSummaryTab({
174 activityPending,354 activityPending,
175 activityError,355 activityError,
176 pendingFinding,356 pendingFinding,
357 fixFindingLabel = "Fix in a thread",
358 fixCheckLabel = "Fix",
177 onFixFinding,359 onFixFinding,
178 onRefresh,360 onRefresh,
179}: {361}: {
...@@ -184,6 +366,8 @@ export function PullRequestSummaryTab({...@@ -184,6 +366,8 @@ export function PullRequestSummaryTab({
184 activityError: string | null;366 activityError: string | null;
185 /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */367 /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */
186 pendingFinding?: string | null;368 pendingFinding?: string | null;
369 fixFindingLabel?: string;
370 fixCheckLabel?: string;
187 onFixFinding?: (finding: PullRequestFinding) => void;371 onFixFinding?: (finding: PullRequestFinding) => void;
188 onRefresh: () => void;372 onRefresh: () => void;
189}) {373}) {
...@@ -211,8 +395,66 @@ export function PullRequestSummaryTab({...@@ -211,8 +395,66 @@ export function PullRequestSummaryTab({
211 void readLocalApi()?.shell.openExternal(url);395 void readLocalApi()?.shell.openExternal(url);
212 };396 };
213397
398 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
399 const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
400 reportFailure: false,
401 });
402 // Keyed by the pull request, like the comment window above it, so an editor left open never
403 // reappears over the next pull request's description.
404 const [bodyScope, setBodyScope] = useState<string | null>(null);
405 const [bodySaving, setBodySaving] = useState(false);
406 // The remark being rewritten, named with the pull request it belongs to: a comment id is the
407 // host's own, and two hosts — or two pull requests on Azure DevOps, which numbers a remark
408 // inside its thread — hand out the same one. Without the pull request beside it, opening a
409 // different one would leave its like-numbered remark sitting open in an editor.
410 const [commentScope, setCommentScope] = useState<{
411 readonly pullRequest: string;
412 readonly commentId: string;
413 } | null>(null);
414 const [commentSaving, setCommentSaving] = useState(false);
415 const editingCommentId = commentScope?.pullRequest === detail.url ? commentScope.commentId : null;
416
417 const saveBody = async (body: string) => {
418 if (bodySaving) return;
419 setBodySaving(true);
420 const result = await update({ environmentId, input: { ...reference, body } });
421 setBodySaving(false);
422 if (result._tag === "Failure") {
423 toastManager.add({ type: "error", title: "Could not save the description" });
424 return;
425 }
426 setBodyScope(null);
427 onRefresh();
428 };
429
430 const commentEditing: CommentEditing = {
431 cwd: detail.workspaceRoot,
432 canEdit: (comment) => canEditPullRequestComment(detail, comment),
433 editingId: editingCommentId,
434 saving: commentSaving,
435 onEdit: (comment) =>
436 setCommentScope(comment === null ? null : { pullRequest: detail.url, commentId: comment.id }),
437 onSave: async (comment, body) => {
438 // A review's own summary is not a kind any host rewrites, which is why no pencil is ever
439 // offered on one; the check is here because the comment's own type still allows it.
440 if (commentSaving || comment.kind === "review") return;
441 setCommentSaving(true);
442 const result = await updateComment({
443 environmentId,
444 input: { ...reference, commentId: comment.id, kind: comment.kind, body },
445 });
446 setCommentSaving(false);
447 if (result._tag === "Failure") {
448 toastManager.add({ type: "error", title: "Could not save the comment" });
449 return;
450 }
451 setCommentScope(null);
452 onRefresh();
453 },
454 };
455
214 return (456 return (
215 <div className="h-full overflow-y-auto">457 <div className="h-full overflow-y-auto" data-pull-request-summary-scroll>
216 <section className="px-4 py-3">458 <section className="px-4 py-3">
217 <div>459 <div>
218 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">460 <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers">
...@@ -261,6 +503,28 @@ export function PullRequestSummaryTab({...@@ -261,6 +503,28 @@ export function PullRequestSummaryTab({
261 ) : null}503 ) : null}
262 </span>504 </span>
263 </MetaRow>505 </MetaRow>
506 {detail.labels.length > 0 ? (
507 <MetaRow icon={<TagIcon className="size-3.5" />} label="Labels">
508 <span className="flex min-w-0 flex-wrap items-center gap-1">
509 {detail.labels.map((label) => {
510 const dot = labelDotColor(label.color);
511 return (
512 <span
513 key={label.name}
514 className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs"
515 >
516 <span
517 aria-hidden
518 className="size-2 shrink-0 rounded-full bg-muted-foreground"
519 {...(dot ? { style: { backgroundColor: dot } } : {})}
520 />
521 <span className="truncate">{label.name}</span>
522 </span>
523 );
524 })}
525 </span>
526 </MetaRow>
527 ) : null}
264 <MetaRow icon={<MessageSquareIcon className="size-3.5" />} label="Comments">528 <MetaRow icon={<MessageSquareIcon className="size-3.5" />} label="Comments">
265 {activityPending529 {activityPending
266 ? "Loading conversation…"530 ? "Loading conversation…"
...@@ -274,10 +538,48 @@ export function PullRequestSummaryTab({...@@ -274,10 +538,48 @@ export function PullRequestSummaryTab({
274 </section>538 </section>
275539
276 <Section title="Description">540 <Section title="Description">
277 <PullRequestMarkdown541 <div className="group">
278 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}542 {bodyScope === detail.url ? (
279 cwd={detail.workspaceRoot}543 <PullRequestMarkdownEditor
280 />544 // Empty is a real answer here: saving nothing is how a description is cleared.
545 allowEmpty
546 value={detail.body}
547 cwd={detail.workspaceRoot}
548 label="Pull request description"
549 placeholder="Describe this pull request"
550 saving={bodySaving}
551 onSave={(body) => void saveBody(body)}
552 onCancel={() => setBodyScope(null)}
553 />
554 ) : (
555 <div className="flex items-start gap-1">
556 <PullRequestMarkdown
557 className="min-w-0 flex-1"
558 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}
559 cwd={detail.workspaceRoot}
560 />
561 {canEditPullRequestChangeRequest(detail) ? (
562 <Button
563 size="icon-xs"
564 variant="ghost"
565 className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
566 aria-label="Edit description"
567 onClick={() => setBodyScope(detail.url)}
568 >
569 <PencilIcon className="size-3" />
570 </Button>
571 ) : null}
572 </div>
573 )}
574 <PullRequestReactionBar
575 className="mt-2"
576 reactions={detail.reactions ?? []}
577 canReact={detail.capabilities.reactions === true}
578 environmentId={environmentId}
579 reference={reference}
580 onRefresh={onRefresh}
581 />
582 </div>
281 </Section>583 </Section>
282584
283 <Section title="Checks" count={detail.checks.length}>585 <Section title="Checks" count={detail.checks.length}>
...@@ -285,12 +587,14 @@ export function PullRequestSummaryTab({...@@ -285,12 +587,14 @@ export function PullRequestSummaryTab({
285 <p className="text-xs text-muted-foreground">No checks reported.</p>587 <p className="text-xs text-muted-foreground">No checks reported.</p>
286 ) : (588 ) : (
287 <div className="space-y-0.5">589 <div className="space-y-0.5">
288 {detail.checks.map((check) => {590 {detail.checks.map((check, index) => {
289 const finding = { kind: "check", check } as const;591 const finding = { kind: "check", check } as const;
290 const failing = check.status === "failure" || check.status === "cancelled";592 const failing = check.status === "failure" || check.status === "cancelled";
291 return (593 return (
292 <div594 <div
293 key={`${check.name}:${check.url ?? ""}`}595 // Position too: the host decides how many runs share a name, and a repeated
596 // key would be a rendering fault on top of whatever the list already says.
597 key={`${index}:${check.name}:${check.url ?? ""}`}
294 className="group flex items-center gap-1 rounded-md pr-1 hover:bg-accent/60"598 className="group flex items-center gap-1 rounded-md pr-1 hover:bg-accent/60"
295 >599 >
296 <button600 <button
...@@ -319,7 +623,9 @@ export function PullRequestSummaryTab({...@@ -319,7 +623,9 @@ export function PullRequestSummaryTab({
319 onClick={() => onFixFinding(finding)}623 onClick={() => onFixFinding(finding)}
320 >624 >
321 <HammerIcon className="size-3" />625 <HammerIcon className="size-3" />
322 {pendingFinding === pullRequestFindingKey(finding) ? "Preparing..." : "Fix"}626 {pendingFinding === pullRequestFindingKey(finding)
627 ? "Preparing..."
628 : fixCheckLabel}
323 </Button>629 </Button>
324 ) : null}630 ) : null}
325 </div>631 </div>
...@@ -385,32 +691,51 @@ export function PullRequestSummaryTab({...@@ -385,32 +691,51 @@ export function PullRequestSummaryTab({
385 ) : null}691 ) : null}
386 {visibleComments.map((comment) => {692 {visibleComments.map((comment) => {
387 const thread = threadByCommentId.get(comment.id);693 const thread = threadByCommentId.get(comment.id);
694 const reviewState = comment.reviewState?.toLowerCase();
695 if (thread?.isResolved || reviewState === "dismissed") {
696 return (
697 <CollapsedComment
698 key={comment.id}
699 comment={comment}
700 editing={commentEditing}
701 label={thread?.isResolved ? "Resolved" : "Approval dismissed"}
702 reactionBar={
703 <PullRequestReactionBar
704 className="mt-2"
705 reactions={comment.reactions ?? []}
706 canReact={detail.capabilities.reactions === true}
707 subjectId={comment.id}
708 environmentId={environmentId}
709 reference={reference}
710 onRefresh={onRefresh}
711 />
712 }
713 />
714 );
715 }
716 // An approval is a verdict, not a finding: there is nothing in it to fix.
388 const finding: PullRequestFinding | null =717 const finding: PullRequestFinding | null =
389 comment.kind !== "review" && comment.kind !== "review-comment"718 (comment.kind !== "review" && comment.kind !== "review-comment") ||
719 reviewState === "approved"
390 ? null720 ? null
391 : thread === undefined721 : thread === undefined
392 ? { kind: "comment", comment }722 ? { kind: "comment", comment }
393 : thread.isResolved723 : { kind: "thread", thread };
394 ? null
395 : { kind: "thread", thread };
396 return (724 return (
397 <article725 <article
398 key={comment.id}726 key={comment.id}
399 // Offscreen comments skip style, layout and paint. Bot comments carry pages of727 // Offscreen comments skip style, layout and paint. Bot comments carry pages of
400 // highlighted code, and the conversation is below the description either way.728 // highlighted code, and the conversation is below the description either way.
401 className="rounded-lg border border-border/60 p-3 [contain-intrinsic-block-size:120px] [content-visibility:auto]"729 className="group rounded-lg border border-border/60 p-3 [contain-intrinsic-block-size:120px] [content-visibility:auto]"
402 >730 >
403 <div className="flex items-start gap-2">731 <div className="flex items-start gap-2">
404 <PullRequestMetaLine className="min-w-0 flex-1 text-xs text-muted-foreground">732 <span className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground">
405 <PullRequestActorLabel733 <CommentAuthor actor={comment.author} />
406 actor={comment.author}
407 className="font-medium text-foreground"
408 />
409 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>734 <span>{formatRelativeTimeLabel(comment.createdAt)}</span>
410 {comment.reviewState ? (735 {comment.reviewState ? (
411 <span>{comment.reviewState.toLowerCase()}</span>736 <span>{reviewStateLabel(comment.reviewState)}</span>
412 ) : null}737 ) : null}
413 </PullRequestMetaLine>738 </span>
414 {/* Review remarks only. A plain conversation comment is talk, not a finding,739 {/* Review remarks only. A plain conversation comment is talk, not a finding,
415 and offering to fix one would promise more than it says. */}740 and offering to fix one would promise more than it says. */}
416 {onFixFinding && finding ? (741 {onFixFinding && finding ? (
...@@ -424,7 +749,7 @@ export function PullRequestSummaryTab({...@@ -424,7 +749,7 @@ export function PullRequestSummaryTab({
424 <HammerIcon className="size-3" />749 <HammerIcon className="size-3" />
425 {pendingFinding === pullRequestFindingKey(finding)750 {pendingFinding === pullRequestFindingKey(finding)
426 ? "Preparing..."751 ? "Preparing..."
427 : "Fix in a thread"}752 : fixFindingLabel}
428 </Button>753 </Button>
429 ) : null}754 ) : null}
430 </div>755 </div>
...@@ -436,10 +761,15 @@ export function PullRequestSummaryTab({...@@ -436,10 +761,15 @@ export function PullRequestSummaryTab({
436 {comment.path}761 {comment.path}
437 </p>762 </p>
438 ) : null}763 ) : null}
439 <PullRequestMarkdown764 <CommentBody className="mt-2" comment={comment} editing={commentEditing} />
765 <PullRequestReactionBar
440 className="mt-2"766 className="mt-2"
441 text={comment.body}767 reactions={comment.reactions ?? []}
442 cwd={detail.workspaceRoot}768 canReact={detail.capabilities.reactions === true}
769 subjectId={comment.id}
770 environmentId={environmentId}
771 reference={reference}
772 onRefresh={onRefresh}
443 />773 />
444 </article>774 </article>
445 );775 );
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+123-4
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import type { PullRequestActor, PullRequestDetailView } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 PullRequestActor,
4 PullRequestComment,
5 PullRequestDetailView,
6 PullRequestRef,
7} from "@t3tools/contracts";
2import {8import {
3 ChevronDownIcon,9 ChevronDownIcon,
4 ExternalLinkIcon,10 ExternalLinkIcon,
...@@ -8,27 +14,42 @@ import {...@@ -8,27 +14,42 @@ import {
8 GitPullRequestClosedIcon,14 GitPullRequestClosedIcon,
9 GitPullRequestIcon,15 GitPullRequestIcon,
10 MessageSquareIcon,16 MessageSquareIcon,
17 PencilIcon,
11} from "lucide-react";18} from "lucide-react";
12import { useState, type ReactNode } from "react";19import { useState, type ReactNode } from "react";
1320
14import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
15import { readLocalApi } from "~/localApi";22import { readLocalApi } from "~/localApi";
23import { pullRequestEnvironment } from "~/state/pullRequests";
24import { useAtomCommand } from "~/state/use-atom-command";
16import { formatRelativeTimeLabel } from "~/timestampFormat";25import { formatRelativeTimeLabel } from "~/timestampFormat";
1726
18import { Button } from "../ui/button";27import { Button } from "../ui/button";
19import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";28import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
29import { toastManager } from "../ui/toast";
20import {30import {
21 buildPullRequestTimeline,31 buildPullRequestTimeline,
22 groupPullRequestTimelineConversations,32 groupPullRequestTimelineConversations,
23 type PullRequestTimelineEvent,33 type PullRequestTimelineEvent,
24} from "./pullRequestDetail.logic";34} from "./pullRequestDetail.logic";
35import { canEditPullRequestComment } from "./pullRequestEditing.logic";
25import { PullRequestMarkdown } from "./PullRequestMarkdown";36import { PullRequestMarkdown } from "./PullRequestMarkdown";
37import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
38import { PullRequestReactionBar } from "./PullRequestReactions";
26import {39import {
27 PullRequestActorAvatar,40 PullRequestActorAvatar,
28 PullRequestDiffStat,41 PullRequestDiffStat,
29 PullRequestMetaLine,42 PullRequestMetaLine,
30} from "./pullRequestPresentation";43} from "./pullRequestPresentation";
3144
45/** What every comment on the timeline needs to react; only the subject differs between them. */
46interface ReactionSurface {
47 readonly canReact: boolean;
48 readonly environmentId: EnvironmentId;
49 readonly reference: PullRequestRef;
50 readonly onRefresh: () => void;
51}
52
32function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) {53function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) {
33 return (54 return (
34 <div className="mt-3">55 <div className="mt-3">
...@@ -130,15 +151,44 @@ function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: s...@@ -130,15 +151,44 @@ function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: s
130151
131function ConversationCard({152function ConversationCard({
132 event,153 event,
154 editable,
133 cwd,155 cwd,
134 onOpen,156 onOpen,
157 reactions,
135}: {158}: {
136 event: PullRequestTimelineEvent;159 event: PullRequestTimelineEvent;
160 /** The remark behind this entry, only where this reader may rewrite it. */
161 editable: PullRequestComment | null;
137 cwd: string;162 cwd: string;
138 onOpen: (url: string) => void;163 onOpen: (url: string) => void;
164 reactions: ReactionSurface;
139}) {165}) {
166 const [editing, setEditing] = useState(false);
167 const [saving, setSaving] = useState(false);
168 const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
169 reportFailure: false,
170 });
171
172 const save = async (body: string) => {
173 // A review's own summary is not a kind any host rewrites, which is why `editable` is never
174 // one; the check is here because the comment's own type still allows it.
175 if (editable === null || saving || editable.kind === "review") return;
176 setSaving(true);
177 const result = await updateComment({
178 environmentId: reactions.environmentId,
179 input: { ...reactions.reference, commentId: editable.id, kind: editable.kind, body },
180 });
181 setSaving(false);
182 if (result._tag === "Failure") {
183 toastManager.add({ type: "error", title: "Could not save the comment" });
184 return;
185 }
186 setEditing(false);
187 reactions.onRefresh();
188 };
189
140 return (190 return (
141 <article className="py-2">191 <article className="group py-2">
142 <div className="px-2">192 <div className="px-2">
143 <div className="flex min-w-0 items-start gap-2">193 <div className="flex min-w-0 items-start gap-2">
144 <div className="min-w-0 flex-1">194 <div className="min-w-0 flex-1">
...@@ -157,14 +207,48 @@ function ConversationCard({...@@ -157,14 +207,48 @@ function ConversationCard({
157 ) : null}207 ) : null}
158 </PullRequestMetaLine>208 </PullRequestMetaLine>
159 </div>209 </div>
210 {editable !== null && !editing ? (
211 <Button
212 size="icon-xs"
213 variant="ghost"
214 className="-mt-1 shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
215 aria-label="Edit comment"
216 onClick={() => setEditing(true)}
217 >
218 <PencilIcon className="size-3" />
219 </Button>
220 ) : null}
160 <OpenOnHostButton url={event.url} onOpen={onOpen} />221 <OpenOnHostButton url={event.url} onOpen={onOpen} />
161 </div>222 </div>
162 </div>223 </div>
163 {event.body ? (224 {editing && editable !== null ? (
225 <div className="px-2 pb-2 pt-3">
226 <PullRequestMarkdownEditor
227 value={editable.body}
228 cwd={cwd}
229 label="Edit comment"
230 saving={saving}
231 onSave={(body) => void save(body)}
232 onCancel={() => setEditing(false)}
233 />
234 </div>
235 ) : event.body ? (
164 <div className="px-2 pb-2">236 <div className="px-2 pb-2">
165 <TimelineBody body={event.body} markdown={event.markdown} cwd={cwd} />237 <TimelineBody body={event.body} markdown={event.markdown} cwd={cwd} />
166 </div>238 </div>
167 ) : null}239 ) : null}
240 {reactions.canReact || event.reactions.length > 0 ? (
241 <div className="px-2 pb-2">
242 <PullRequestReactionBar
243 reactions={event.reactions}
244 canReact={reactions.canReact}
245 subjectId={event.id}
246 environmentId={reactions.environmentId}
247 reference={reactions.reference}
248 onRefresh={reactions.onRefresh}
249 />
250 </div>
251 ) : null}
168 </article>252 </article>
169 );253 );
170}254}
...@@ -180,12 +264,16 @@ function uniqueConversationActors(events: ReadonlyArray<PullRequestTimelineEvent...@@ -180,12 +264,16 @@ function uniqueConversationActors(events: ReadonlyArray<PullRequestTimelineEvent
180264
181function ConversationGroup({265function ConversationGroup({
182 events,266 events,
267 editable,
183 cwd,268 cwd,
184 onOpen,269 onOpen,
270 reactions,
185}: {271}: {
186 events: ReadonlyArray<PullRequestTimelineEvent>;272 events: ReadonlyArray<PullRequestTimelineEvent>;
273 editable: ReadonlyMap<string, PullRequestComment>;
187 cwd: string;274 cwd: string;
188 onOpen: (url: string) => void;275 onOpen: (url: string) => void;
276 reactions: ReactionSurface;
189}) {277}) {
190 const [open, setOpen] = useState(false);278 const [open, setOpen] = useState(false);
191 const actors = uniqueConversationActors(events);279 const actors = uniqueConversationActors(events);
...@@ -229,7 +317,17 @@ function ConversationGroup({...@@ -229,7 +317,17 @@ function ConversationGroup({
229 {open ? (317 {open ? (
230 <div className="mt-1 space-y-1">318 <div className="mt-1 space-y-1">
231 {events.map((event) => (319 {events.map((event) => (
232 <ConversationCard key={event.id} event={event} cwd={cwd} onOpen={onOpen} />320 <ConversationCard
321 // Named with the pull request too: a remark's id is the host's own, and two
322 // pull requests can hand out the same one — which would leave one card's open
323 // editor standing over the other's remark.
324 key={`${reactions.reference.projectId}#${reactions.reference.number}:${event.id}`}
325 event={event}
326 editable={editable.get(event.id) ?? null}
327 cwd={cwd}
328 onOpen={onOpen}
329 reactions={reactions}
330 />
233 ))}331 ))}
234 </div>332 </div>
235 ) : null}333 ) : null}
...@@ -315,14 +413,33 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {...@@ -315,14 +413,33 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
315413
316export function PullRequestTimelineTab({414export function PullRequestTimelineTab({
317 detail,415 detail,
416 environmentId,
417 reference,
318 order,418 order,
319 onOpenCommit,419 onOpenCommit,
420 onRefresh,
320}: {421}: {
321 detail: PullRequestDetailView;422 detail: PullRequestDetailView;
423 environmentId: EnvironmentId;
424 reference: PullRequestRef;
322 order: "newest" | "oldest";425 order: "newest" | "oldest";
323 onOpenCommit: (oid: string) => void;426 onOpenCommit: (oid: string) => void;
427 onRefresh: () => void;
324}) {428}) {
325 const events = buildPullRequestTimeline(detail);429 const events = buildPullRequestTimeline(detail);
430 const reactions: ReactionSurface = {
431 canReact: detail.capabilities.reactions === true,
432 environmentId,
433 reference,
434 onRefresh,
435 };
436 // A timeline entry keeps only what it draws, so the remarks this reader may rewrite are looked
437 // up here by the id the entry carries.
438 const editable = new Map(
439 detail.comments
440 .filter((comment) => canEditPullRequestComment(detail, comment))
441 .map((comment) => [comment.id, comment] as const),
442 );
326 const orderedEvents = order === "newest" ? events : events.toReversed();443 const orderedEvents = order === "newest" ? events : events.toReversed();
327 const rows = groupPullRequestTimelineConversations(orderedEvents);444 const rows = groupPullRequestTimelineConversations(orderedEvents);
328 const openOnHost = (url: string) => {445 const openOnHost = (url: string) => {
...@@ -340,8 +457,10 @@ export function PullRequestTimelineTab({...@@ -340,8 +457,10 @@ export function PullRequestTimelineTab({
340 <ConversationGroup457 <ConversationGroup
341 key={`comments:${row.events[0]?.id ?? "empty"}`}458 key={`comments:${row.events[0]?.id ?? "empty"}`}
342 events={row.events}459 events={row.events}
460 editable={editable}
343 cwd={detail.workspaceRoot}461 cwd={detail.workspaceRoot}
344 onOpen={openOnHost}462 onOpen={openOnHost}
463 reactions={reactions}
345 />464 />
346 );465 );
347 }466 }
apps/web/src/components/pullRequest/pullRequestChecks.test.tsx created+85
...@@ -0,0 +1,85 @@
1import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts";
2import { Children, isValidElement, type ReactNode } from "react";
3import { describe, expect, it } from "vite-plus/test";
4
5import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
6import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
7import { PullRequestRow } from "./PullRequestRow";
8import { pullRequestChecksState } from "./pullRequestPresentation";
9
10function check(status: PullRequestCheck["status"]): PullRequestCheck {
11 return { name: `check-${status}`, status, description: null, url: null };
12}
13
14describe("pullRequestChecksState", () => {
15 it("lets a failure outrank a run still going, and reports nothing without checks", () => {
16 expect(pullRequestChecksState([check("success"), check("pending"), check("failure")])).toBe(
17 "failing",
18 );
19 expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing");
20 expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending");
21 expect(pullRequestChecksState([check("success")])).toBe("passing");
22 // Skipped and neutral are neither a pass nor a failure, so they are no verdict at all.
23 expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null);
24 expect(pullRequestChecksState([])).toBe(null);
25 });
26});
27
28/** Every element of the tree the row returned, so a nested indicator can be looked for. */
29function flatten(node: ReactNode): ReadonlyArray<ReturnType<typeof Object>> {
30 const found: unknown[] = [];
31 for (const child of Children.toArray(node)) {
32 if (!isValidElement(child)) continue;
33 found.push(child);
34 found.push(...flatten((child.props as { readonly children?: ReactNode }).children));
35 }
36 return found as ReadonlyArray<ReturnType<typeof Object>>;
37}
38
39function entry(overrides: Partial<EnvironmentPullRequestEntry>): EnvironmentPullRequestEntry {
40 return {
41 environmentId: "env-1" as EnvironmentId,
42 projectId: "project-1" as ProjectId,
43 provider: "github",
44 repository: "pingdotgg/t3code",
45 number: 1,
46 title: "Add the pull requests page",
47 url: "https://github.com/pingdotgg/t3code/pull/1",
48 author: null,
49 headBranch: "feat/page",
50 baseBranch: "main",
51 state: "open",
52 isDraft: false,
53 mergeability: "mergeable",
54 additions: 1,
55 deletions: 0,
56 createdAt: "2026-07-01T00:00:00Z",
57 updatedAt: "2026-07-02T00:00:00Z",
58 viewerReviewRequested: false,
59 labels: [],
60 ...overrides,
61 } as EnvironmentPullRequestEntry;
62}
63
64function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {
65 return PullRequestRow.type({
66 entry: entry(overrides),
67 selected: false,
68 showProjectTitle: false,
69 showProvider: false,
70 onSelect: () => {},
71 });
72}
73
74describe("PullRequestRow checks indicator", () => {
75 function indicators(node: ReactNode): number {
76 return flatten(node).filter(
77 (element) => (element as { type?: unknown }).type === PullRequestChecksPopover,
78 ).length;
79 }
80
81 it("shows the indicator only for a row the host reported a rollup for", () => {
82 expect(indicators(row({ checksState: "failing" }))).toBe(1);
83 expect(indicators(row({}))).toBe(0);
84 });
85});
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+155-5
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import type {1import {
2 PullRequestCheck,2 PullRequestAction,
3 PullRequestComment,3 type PullRequestCheck,
4 PullRequestDetailView,4 type PullRequestComment,
5 PullRequestReviewThread,5 type PullRequestDetailView,
6 type PullRequestReviewThread,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
89
...@@ -15,9 +16,13 @@ import {...@@ -15,9 +16,13 @@ import {
15 groupPullRequestTimelineConversations,16 groupPullRequestTimelineConversations,
16 handoffPrompt,17 handoffPrompt,
17 handoffReviewComments,18 handoffReviewComments,
19 isThreadOwnPullRequest,
18 orderPullRequestComments,20 orderPullRequestComments,
21 pullRequestActionNeedsHostRefresh,
19 pullRequestFindingKey,22 pullRequestFindingKey,
23 pullRequestHandoffLabels,
20 readableFailure,24 readableFailure,
25 resolveBaseFreshness,
21 buildPullRequestTimeline,26 buildPullRequestTimeline,
22 describePullRequestState,27 describePullRequestState,
23} from "./pullRequestDetail.logic";28} from "./pullRequestDetail.logic";
...@@ -57,6 +62,28 @@ describe("pull request state description", () => {...@@ -57,6 +62,28 @@ describe("pull request state description", () => {
57 });62 });
58});63});
5964
65describe("pull request handoff labels", () => {
66 it("names the open thread when actions write to its composer", () => {
67 expect(pullRequestHandoffLabels(true)).toEqual({
68 fixFinding: "Fix in this thread",
69 fixCheck: "Fix in this thread",
70 fixFindings: "Fix findings in this thread",
71 resolve: "Resolve in this thread",
72 resolveConflicts: "Resolve conflicts in this thread",
73 });
74 });
75
76 it("keeps the standalone pull request page labels", () => {
77 expect(pullRequestHandoffLabels(false)).toEqual({
78 fixFinding: "Fix in a thread",
79 fixCheck: "Fix",
80 fixFindings: "Fix findings in a thread",
81 resolve: "Resolve in a new thread",
82 resolveConflicts: "Resolve conflicts in a thread",
83 });
84 });
85});
86
60describe("ordering comments", () => {87describe("ordering comments", () => {
61 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {88 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {
62 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];89 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];
...@@ -787,3 +814,126 @@ describe("a second ask into the same composer", () => {...@@ -787,3 +814,126 @@ describe("a second ask into the same composer", () => {
787 ]);814 ]);
788 });815 });
789});816});
817
818describe("how the branch stands against its base", () => {
819 const detail = (overrides: Record<string, unknown> = {}) =>
820 ({
821 state: "open",
822 mergeability: "mergeable",
823 baseComparison: "behind",
824 behindBy: 12,
825 capabilities: { updateMethods: ["merge", "rebase"] },
826 viewerPermissions: { updateMethods: ["merge", "rebase"] },
827 ...overrides,
828 }) as Parameters<typeof resolveBaseFreshness>[0];
829
830 it("offers both ways where the host and the reader both allow them", () => {
831 expect(resolveBaseFreshness(detail())).toEqual({ behindBy: 12, methods: ["merge", "rebase"] });
832 });
833
834 it("says nothing about a branch that is already current", () => {
835 expect(resolveBaseFreshness(detail({ baseComparison: "up-to-date" }))).toBeNull();
836 });
837
838 it("says nothing where the host could not compare, rather than claiming it is current", () => {
839 expect(resolveBaseFreshness(detail({ baseComparison: "unknown" }))).toBeNull();
840 expect(resolveBaseFreshness(detail({ baseComparison: undefined }))).toBeNull();
841 });
842
843 it("leaves a conflicting branch to the conflicts row", () => {
844 expect(resolveBaseFreshness(detail({ mergeability: "conflicting" }))).toBeNull();
845 });
846
847 it("says nothing where the host has no merge verdict yet", () => {
848 expect(resolveBaseFreshness(detail({ mergeability: "unknown" }))).toBeNull();
849 });
850
851 it("says nothing about a merged or closed pull request", () => {
852 expect(resolveBaseFreshness(detail({ state: "merged" }))).toBeNull();
853 expect(resolveBaseFreshness(detail({ state: "closed" }))).toBeNull();
854 });
855
856 it("narrows to what this reader may actually take", () => {
857 expect(
858 resolveBaseFreshness(detail({ viewerPermissions: { updateMethods: ["merge"] } }))?.methods,
859 ).toEqual(["merge"]);
860 });
861
862 it("still reports the news where the reader may take none of it", () => {
863 // Somebody reading another account's pull request is told why it is blocked without being
864 // offered a button the host would refuse.
865 expect(resolveBaseFreshness(detail({ viewerPermissions: {} }))).toEqual({
866 behindBy: 12,
867 methods: [],
868 });
869 });
870
871 it("reports a count only where the host counted", () => {
872 expect(resolveBaseFreshness(detail({ behindBy: undefined }))?.behindBy).toBeNull();
873 });
874});
875
876describe("whether the panel is showing the thread's own pull request", () => {
877 const surface = { projectId: "proj-a", repository: "acme/app", number: 7 };
878
879 it("matches on project, repository and number together", () => {
880 expect(
881 isThreadOwnPullRequest({ projectId: "proj-a", repository: "acme/app", number: 7 }, surface),
882 ).toBe(true);
883 });
884
885 it("rejects a second checkout of the same repository under another project", () => {
886 expect(
887 isThreadOwnPullRequest({ projectId: "proj-b", repository: "acme/app", number: 7 }, surface),
888 ).toBe(false);
889 });
890
891 it("rejects another repository or another number", () => {
892 expect(
893 isThreadOwnPullRequest({ projectId: "proj-a", repository: "acme/web", number: 7 }, surface),
894 ).toBe(false);
895 expect(
896 isThreadOwnPullRequest({ projectId: "proj-a", repository: "acme/app", number: 8 }, surface),
897 ).toBe(false);
898 });
899
900 it("rejects a thread with no project or no pull request of its own", () => {
901 expect(
902 isThreadOwnPullRequest({ projectId: null, repository: "acme/app", number: 7 }, surface),
903 ).toBe(false);
904 expect(
905 isThreadOwnPullRequest(
906 { projectId: "proj-a", repository: "acme/app", number: null },
907 surface,
908 ),
909 ).toBe(false);
910 });
911});
912
913describe("which actions need the host read again after they run", () => {
914 it("classifies every action the contract knows about", () => {
915 // Imported from the contract rather than hand-listed, so a new PullRequestAction fails this
916 // test until somebody decides which side of the diff it belongs on.
917 expect(PullRequestAction.literals.map(pullRequestActionNeedsHostRefresh)).toEqual(
918 PullRequestAction.literals.map((action) => action === "update-branch"),
919 );
920 });
921
922 it("sends update-branch back to the host, having moved the head commit", () => {
923 expect(pullRequestActionNeedsHostRefresh("update-branch")).toBe(true);
924 });
925
926 it("leaves every action that only changes metadata to the cheaper detail refresh", () => {
927 for (const action of [
928 "ready",
929 "draft",
930 "close",
931 "reopen",
932 "enable-auto-merge",
933 "disable-auto-merge",
934 "merge",
935 ] as const) {
936 expect(pullRequestActionNeedsHostRefresh(action)).toBe(false);
937 }
938 });
939});
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+114
...@@ -1,14 +1,62 @@...@@ -1,14 +1,62 @@
1import type {1import type {
2 PullRequestAction,
2 PullRequestActor,3 PullRequestActor,
4 PullRequestBaseComparison,
3 PullRequestCheck,5 PullRequestCheck,
4 PullRequestComment,6 PullRequestComment,
5 PullRequestDetailView,7 PullRequestDetailView,
8 PullRequestMergeability,
9 PullRequestReaction,
6 PullRequestReviewThread,10 PullRequestReviewThread,
7 PullRequestState,11 PullRequestState,
12 PullRequestUpdateMethod,
8} from "@t3tools/contracts";13} from "@t3tools/contracts";
914
10import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";15import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
1116
17/**
18 * Whether the pull request on a right-panel surface is the thread's own one. Repository and
19 * number are not enough: one environment can hold two checkouts of the same repository under
20 * different projects, and the other project's checkout is somebody else's branch.
21 */
22export function isThreadOwnPullRequest(
23 thread: {
24 readonly projectId: string | null;
25 readonly repository: string | null;
26 readonly number: number | null;
27 },
28 surface: {
29 readonly projectId: string;
30 readonly repository: string;
31 readonly number: number;
32 },
33): boolean {
34 return (
35 thread.projectId === surface.projectId &&
36 thread.repository === surface.repository &&
37 thread.number === surface.number
38 );
39}
40
41/** Names where a pull-request task will land, without letting each surface guess independently. */
42export function pullRequestHandoffLabels(inThisThread: boolean) {
43 return inThisThread
44 ? {
45 fixFinding: "Fix in this thread",
46 fixCheck: "Fix in this thread",
47 fixFindings: "Fix findings in this thread",
48 resolve: "Resolve in this thread",
49 resolveConflicts: "Resolve conflicts in this thread",
50 }
51 : {
52 fixFinding: "Fix in a thread",
53 fixCheck: "Fix",
54 fixFindings: "Fix findings in a thread",
55 resolve: "Resolve in a new thread",
56 resolveConflicts: "Resolve conflicts in a thread",
57 };
58}
59
12/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */60/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */
13export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {61export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {
14 if (state === "merged") return "Merged";62 if (state === "merged") return "Merged";
...@@ -41,6 +89,8 @@ export interface PullRequestTimelineEvent {...@@ -41,6 +89,8 @@ export interface PullRequestTimelineEvent {
41 readonly deletions: number | null;89 readonly deletions: number | null;
42 readonly path: string | null;90 readonly path: string | null;
43 readonly reviewState: string | null;91 readonly reviewState: string | null;
92 /** Empty for everything but a comment, which is the only entry a host lets anyone react to. */
93 readonly reactions: ReadonlyArray<PullRequestReaction>;
44}94}
4595
46export type PullRequestTimelineRow =96export type PullRequestTimelineRow =
...@@ -111,6 +161,7 @@ export function buildPullRequestTimeline(...@@ -111,6 +161,7 @@ export function buildPullRequestTimeline(
111 deletions: null,161 deletions: null,
112 path: null,162 path: null,
113 reviewState: null,163 reviewState: null,
164 reactions: [],
114 },165 },
115 ...detail.commits.map((commit) => ({166 ...detail.commits.map((commit) => ({
116 id: commit.oid,167 id: commit.oid,
...@@ -126,6 +177,7 @@ export function buildPullRequestTimeline(...@@ -126,6 +177,7 @@ export function buildPullRequestTimeline(
126 deletions: commit.deletions ?? null,177 deletions: commit.deletions ?? null,
127 path: null,178 path: null,
128 reviewState: null,179 reviewState: null,
180 reactions: [],
129 })),181 })),
130 ...detail.comments.map((comment) => ({182 ...detail.comments.map((comment) => ({
131 id: comment.id,183 id: comment.id,
...@@ -141,6 +193,7 @@ export function buildPullRequestTimeline(...@@ -141,6 +193,7 @@ export function buildPullRequestTimeline(
141 deletions: null,193 deletions: null,
142 path: comment.path,194 path: comment.path,
143 reviewState: comment.reviewState,195 reviewState: comment.reviewState,
196 reactions: comment.reactions ?? [],
144 })),197 })),
145 ...(detail.mergedAt198 ...(detail.mergedAt
146 ? [199 ? [
...@@ -158,6 +211,7 @@ export function buildPullRequestTimeline(...@@ -158,6 +211,7 @@ export function buildPullRequestTimeline(
158 deletions: null,211 deletions: null,
159 path: null,212 path: null,
160 reviewState: null,213 reviewState: null,
214 reactions: [],
161 },215 },
162 ]216 ]
163 : []),217 : []),
...@@ -177,6 +231,7 @@ export function buildPullRequestTimeline(...@@ -177,6 +231,7 @@ export function buildPullRequestTimeline(
177 deletions: null,231 deletions: null,
178 path: null,232 path: null,
179 reviewState: null,233 reviewState: null,
234 reactions: [],
180 },235 },
181 ]236 ]
182 : []),237 : []),
...@@ -648,3 +703,62 @@ export function readableFailure(failure: unknown, hint: string): string {...@@ -648,3 +703,62 @@ export function readableFailure(failure: unknown, hint: string): string {
648 // that contradicts it is worse than no guess at all.703 // that contradicts it is worse than no guess at all.
649 return bounded;704 return bounded;
650}705}
706
707/**
708 * Where the branch stands against its base, said the way GitHub says it: current, out of date but
709 * still cleanly mergeable, or conflicting. Only the middle one is an offer — the conflicts row
710 * already speaks for a branch that collides, and a current branch has nothing to report.
711 *
712 * Null where there is nothing to show, which is also every host that cannot compare or has not
713 * said yet: silence is not the same claim as "up to date", and a banner nobody can act on is
714 * noise. Only a host verdict of "mergeable" earns the clean-merge wording.
715 */
716export function resolveBaseFreshness(detail: {
717 readonly state: PullRequestState;
718 readonly mergeability: PullRequestMergeability;
719 readonly baseComparison?: PullRequestBaseComparison | undefined;
720 readonly behindBy?: number | undefined;
721 readonly capabilities: {
722 readonly updateMethods?: ReadonlyArray<PullRequestUpdateMethod> | undefined;
723 };
724 readonly viewerPermissions: {
725 readonly updateMethods?: ReadonlyArray<PullRequestUpdateMethod> | undefined;
726 };
727}): {
728 readonly behindBy: number | null;
729 /** Empty where the branch is stale but this reader may not move it: news, not an offer. */
730 readonly methods: ReadonlyArray<PullRequestUpdateMethod>;
731} | null {
732 if (detail.state !== "open" || detail.baseComparison !== "behind") return null;
733 // A conflicting branch cannot be updated cleanly either, and the conflicts row is already
734 // saying the more useful half of that. An unknown verdict is not a clean merge in waiting.
735 if (detail.mergeability !== "mergeable") return null;
736 const offered = detail.capabilities.updateMethods ?? [];
737 const allowed = detail.viewerPermissions.updateMethods ?? [];
738 return {
739 behindBy: detail.behindBy ?? null,
740 methods: offered.filter((method) => allowed.includes(method)),
741 };
742}
743
744/**
745 * Whether a completed action leaves the diff atom pointed at a comparison that no longer exists,
746 * the same staleness the manual refresh button fixes. Only `update-branch` moves the head commit;
747 * a merge moves the branch too, but it also closes the pull request, where the diff is no longer
748 * what anyone is looking at. Written as a `Record` so a new `PullRequestAction` fails to compile
749 * here until somebody decides which side of the diff it belongs on.
750 */
751const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = {
752 "update-branch": true,
753 merge: false,
754 ready: false,
755 draft: false,
756 close: false,
757 reopen: false,
758 "enable-auto-merge": false,
759 "disable-auto-merge": false,
760};
761
762export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean {
763 return ACTION_NEEDS_HOST_REFRESH[action];
764}
apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts created+172
...@@ -0,0 +1,172 @@
1import type {
2 PullRequestActor,
3 PullRequestCapabilities,
4 PullRequestComment,
5 PullRequestDetail,
6 PullRequestViewerPermissions,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canEditPullRequestChangeRequest,
12 canEditPullRequestComment,
13} from "./pullRequestEditing.logic";
14
15type Subject = Pick<PullRequestDetail, "author" | "capabilities" | "viewer" | "viewerPermissions">;
16
17function actor(login: string): PullRequestActor {
18 return { login, name: null, avatarUrl: null };
19}
20
21function capabilities(edit?: PullRequestCapabilities["edit"]): PullRequestCapabilities {
22 return {
23 diff: true,
24 comment: true,
25 actions: [],
26 mergeMethods: [],
27 search: true,
28 reactions: true,
29 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
30 reviewers: { request: true, listCandidates: true },
31 ...(edit === undefined ? {} : { edit }),
32 };
33}
34
35function permissions(
36 overrides: Partial<PullRequestViewerPermissions> = {},
37): PullRequestViewerPermissions {
38 return {
39 actions: [],
40 comment: true,
41 resolve: true,
42 verdicts: [],
43 requestReviewers: true,
44 ...overrides,
45 };
46}
47
48function subject(overrides: Partial<Subject> = {}): Subject {
49 return {
50 author: actor("octocat"),
51 capabilities: capabilities({ changeRequest: true, comment: true }),
52 viewer: "octocat",
53 viewerPermissions: permissions(),
54 ...overrides,
55 };
56}
57
58function comment(overrides: Partial<PullRequestComment> = {}): PullRequestComment {
59 return {
60 id: "c1",
61 kind: "issue-comment",
62 author: actor("octocat"),
63 body: "words",
64 createdAt: "2026-01-01T00:00:00.000Z",
65 url: null,
66 path: null,
67 reviewState: null,
68 ...overrides,
69 };
70}
71
72describe("canEditPullRequestChangeRequest", () => {
73 it("lets the author rewrite their own change request", () => {
74 expect(canEditPullRequestChangeRequest(subject())).toBe(true);
75 });
76
77 it("matches the author regardless of case", () => {
78 expect(canEditPullRequestChangeRequest(subject({ viewer: "OctoCat" }))).toBe(true);
79 });
80
81 it("lets somebody who may merge rewrite a change request they did not write", () => {
82 expect(
83 canEditPullRequestChangeRequest(
84 subject({
85 author: actor("someone-else"),
86 viewerPermissions: permissions({ actions: ["merge"] }),
87 }),
88 ),
89 ).toBe(true);
90 });
91
92 it("refuses a reader who neither wrote it nor may merge it", () => {
93 expect(
94 canEditPullRequestChangeRequest(
95 subject({
96 author: actor("someone-else"),
97 viewerPermissions: permissions({ actions: ["close"] }),
98 }),
99 ),
100 ).toBe(false);
101 });
102
103 it("refuses where the host cannot rewrite a change request at all", () => {
104 expect(
105 canEditPullRequestChangeRequest(
106 subject({ capabilities: capabilities({ changeRequest: false, comment: true }) }),
107 ),
108 ).toBe(false);
109 expect(canEditPullRequestChangeRequest(subject({ capabilities: capabilities() }))).toBe(false);
110 });
111
112 it("refuses where the host did not say who the reader is", () => {
113 expect(canEditPullRequestChangeRequest(subject({ viewer: undefined }))).toBe(false);
114 });
115
116 it("still allows a merger the host could not name", () => {
117 expect(
118 canEditPullRequestChangeRequest(
119 subject({ viewer: undefined, viewerPermissions: permissions({ actions: ["merge"] }) }),
120 ),
121 ).toBe(true);
122 });
123});
124
125describe("canEditPullRequestComment", () => {
126 it("lets the reader rewrite their own conversation comment", () => {
127 expect(canEditPullRequestComment(subject(), comment())).toBe(true);
128 });
129
130 it("lets the reader rewrite their own remark on a line", () => {
131 expect(canEditPullRequestComment(subject(), comment({ kind: "review-comment" }))).toBe(true);
132 });
133
134 it("matches the author regardless of case", () => {
135 expect(
136 canEditPullRequestComment(
137 subject({ viewer: "OCTOCAT" }),
138 comment({ author: actor("octocat") }),
139 ),
140 ).toBe(true);
141 });
142
143 it("refuses somebody else's remark", () => {
144 expect(canEditPullRequestComment(subject(), comment({ author: actor("someone-else") }))).toBe(
145 false,
146 );
147 });
148
149 it("refuses a remark the host attributes to nobody", () => {
150 expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false);
151 });
152
153 it("refuses a review's own summary", () => {
154 expect(canEditPullRequestComment(subject(), comment({ kind: "review" }))).toBe(false);
155 });
156
157 it("refuses where the host cannot rewrite a remark at all", () => {
158 expect(
159 canEditPullRequestComment(
160 subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }),
161 comment(),
162 ),
163 ).toBe(false);
164 expect(canEditPullRequestComment(subject({ capabilities: capabilities() }), comment())).toBe(
165 false,
166 );
167 });
168
169 it("refuses where the host did not say who the reader is", () => {
170 expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false);
171 });
172});
apps/web/src/components/pullRequest/pullRequestEditing.logic.ts created+41
...@@ -0,0 +1,41 @@
1import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts";
2
3/** Only the parts of a detail either answer reads, so a caller can pass a whole detail view. */
4type EditingSubject = Pick<
5 PullRequestDetail,
6 "author" | "capabilities" | "viewer" | "viewerPermissions"
7>;
8
9/** Hosts disagree about the case of a login and none of them treats two casings as two people. */
10function sameLogin(one: string | null | undefined, other: string | null | undefined): boolean {
11 if (one == null || other == null) return false;
12 return one.trim().toLowerCase() === other.trim().toLowerCase();
13}
14
15/**
16 * Whether the title and description can be rewritten from here. Beyond the host being able to at
17 * all, either the reader wrote the change request or they may merge it — merging is the one action
18 * every host here grants with write access and withholds without it, so it stands in for the
19 * permission none of them publishes by name.
20 */
21export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean {
22 if (detail.capabilities.edit?.changeRequest !== true) return false;
23 return (
24 sameLogin(detail.viewer, detail.author?.login) ||
25 detail.viewerPermissions.actions.includes("merge")
26 );
27}
28
29/**
30 * Whether this remark can be rewritten from here. A review's own summary is left out: the hosts
31 * disagree about what one even is, and none of them takes a rewrite of it through the mutation
32 * a comment goes through.
33 */
34export function canEditPullRequestComment(
35 detail: EditingSubject,
36 comment: Pick<PullRequestComment, "author" | "kind">,
37): boolean {
38 if (detail.capabilities.edit?.comment !== true) return false;
39 if (comment.kind !== "issue-comment" && comment.kind !== "review-comment") return false;
40 return sameLogin(detail.viewer, comment.author?.login);
41}
apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts created+167
...@@ -0,0 +1,167 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";
3
4import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic";
5
6/** Only the path and the patch's own lines matter here; the viewer fills the rest in. */
7function file(name: string, additionLines: ReadonlyArray<string> = []): FileDiffMetadata {
8 return { name, hunks: [], additionLines, deletionLines: [] } as unknown as FileDiffMetadata;
9}
10
11function order(files: ReadonlyArray<FileDiffMetadata>): Array<string> {
12 return orderDiffFiles(files).map((entry) => entry.name);
13}
14
15describe("diffFileTier", () => {
16 it("puts lockfiles, snapshots and build output last", () => {
17 expect(diffFileTier("pnpm-lock.yaml")).toBe("generated");
18 expect(diffFileTier("apps/web/package-lock.json")).toBe("generated");
19 expect(diffFileTier("src/__snapshots__/app.ts")).toBe("generated");
20 expect(diffFileTier("src/app.test.ts.snap")).toBe("generated");
21 expect(diffFileTier("src/api.generated.ts")).toBe("generated");
22 expect(diffFileTier("public/app.min.js")).toBe("generated");
23 expect(diffFileTier("dist/app.js")).toBe("generated");
24 expect(diffFileTier("packages/core/vendor/lib.js")).toBe("generated");
25 });
26
27 it("recognises a test by its name or by the directory holding it", () => {
28 expect(diffFileTier("src/app.test.ts")).toBe("test");
29 expect(diffFileTier("src/app.spec.tsx")).toBe("test");
30 expect(diffFileTier("src/__tests__/app.ts")).toBe("test");
31 expect(diffFileTier("test/app.ts")).toBe("test");
32 expect(diffFileTier("tests/helpers/app.ts")).toBe("test");
33 });
34
35 it("treats everything else as source, including files merely named like a directory", () => {
36 expect(diffFileTier("src/app.ts")).toBe("source");
37 expect(diffFileTier("src/testing.ts")).toBe("source");
38 expect(diffFileTier("src/dist.ts")).toBe("source");
39 });
40});
41
42describe("orderDiffFiles", () => {
43 it("answers an empty diff with an empty order", () => {
44 expect(order([])).toEqual([]);
45 });
46
47 it("puts a dependency before what imports it", () => {
48 expect(order([file("src/a.ts", ['import { b } from "./b";']), file("src/b.ts")])).toEqual([
49 "src/b.ts",
50 "src/a.ts",
51 ]);
52 });
53
54 it("follows a chain the whole way down", () => {
55 expect(
56 order([
57 file("src/a.ts", ['import { b } from "./b";']),
58 file("src/b.ts", ['import { c } from "./c";']),
59 file("src/c.ts"),
60 ]),
61 ).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]);
62 });
63
64 it("resolves a specifier that climbs out of the importer's directory", () => {
65 expect(
66 order([file("src/ui/a.ts", ['import { b } from "../lib/b";']), file("src/lib/b.ts")]),
67 ).toEqual(["src/lib/b.ts", "src/ui/a.ts"]);
68 });
69
70 it("resolves a directory specifier to that directory's index file", () => {
71 expect(
72 order([file("src/a.ts", ['import { b } from "./lib";']), file("src/lib/index.ts")]),
73 ).toEqual(["src/lib/index.ts", "src/a.ts"]);
74 });
75
76 it("falls back to the specifier's last segment when the path does not line up", () => {
77 // An aliased import (`~/lib/b`) resolves to nothing on disk here, but the diff has only one
78 // file that could be meant.
79 expect(order([file("src/a.ts", ['import { b } from "~/lib/b";']), file("lib/b.ts")])).toEqual([
80 "lib/b.ts",
81 "src/a.ts",
82 ]);
83 });
84
85 it("ignores an ambiguous last segment rather than guessing", () => {
86 expect(
87 order([
88 file("src/a.ts", ['import { b } from "~/somewhere/b";']),
89 file("one/b.ts"),
90 file("two/b.ts"),
91 ]),
92 ).toEqual(["one/b.ts", "src/a.ts", "two/b.ts"]);
93 });
94
95 it("resolves a specifier naming an extension, including the .js beside a .ts", () => {
96 expect(order([file("src/a.ts", ['import { b } from "./b.js";']), file("src/b.ts")])).toEqual([
97 "src/b.ts",
98 "src/a.ts",
99 ]);
100 });
101
102 it("reads require and bare imports too", () => {
103 expect(
104 order([
105 file("src/a.ts", ['const b = require("./b");', 'import "./c";']),
106 file("src/b.ts"),
107 file("src/c.ts"),
108 ]),
109 ).toEqual(["src/b.ts", "src/c.ts", "src/a.ts"]);
110 });
111
112 it("falls back to path order inside a cycle", () => {
113 expect(
114 order([
115 file("src/b.ts", ['import { a } from "./a";']),
116 file("src/a.ts", ['import { b } from "./b";']),
117 ]),
118 ).toEqual(["src/a.ts", "src/b.ts"]);
119 });
120
121 it("clusters unrelated files by path", () => {
122 expect(order([file("src/ui/b.ts"), file("src/lib/z.ts"), file("src/lib/a.ts")])).toEqual([
123 "src/lib/a.ts",
124 "src/lib/z.ts",
125 "src/ui/b.ts",
126 ]);
127 });
128
129 it("keeps the tiers apart and orders tests by the source they cover", () => {
130 expect(
131 order([
132 file("pnpm-lock.yaml"),
133 file("src/a.test.ts"),
134 file("src/__tests__/b.ts"),
135 file("src/a.ts", ['import { b } from "./b";']),
136 file("src/b.ts"),
137 file("dist/a.js"),
138 ]),
139 ).toEqual([
140 "src/b.ts",
141 "src/a.ts",
142 "src/__tests__/b.ts",
143 "src/a.test.ts",
144 "dist/a.js",
145 "pnpm-lock.yaml",
146 ]);
147 });
148
149 it("puts a test with no source in the diff after the ones that have theirs", () => {
150 expect(order([file("src/orphan.spec.ts"), file("src/a.test.ts"), file("src/a.ts")])).toEqual([
151 "src/a.ts",
152 "src/a.test.ts",
153 "src/orphan.spec.ts",
154 ]);
155 });
156
157 it("orders the same diff the same way whatever order it arrives in", () => {
158 const files = [
159 file("src/a.ts", ['import { b } from "./b";']),
160 file("src/b.ts"),
161 file("src/c.ts"),
162 file("src/a.test.ts"),
163 file("yarn.lock"),
164 ];
165 expect(order(files)).toEqual(order(files.toReversed()));
166 });
167});
apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts created+197
...@@ -0,0 +1,197 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2
3import { resolveFileDiffPath } from "~/lib/diffRendering";
4
5/**
6 * Which of the three reading passes a file belongs to.
7 *
8 * A reviewer reads the change itself first, then what proves it, and a lockfile or a build output
9 * only ever confirms what the source already said. Ordering by path alone buries the one file the
10 * change is really about under whichever directory happens to sort first.
11 */
12export type DiffFileTier = "source" | "test" | "generated";
13
14const GENERATED_FILE_NAMES = new Set([
15 "pnpm-lock.yaml",
16 "yarn.lock",
17 "package-lock.json",
18 "bun.lockb",
19 "Cargo.lock",
20 "go.sum",
21 "composer.lock",
22 "Gemfile.lock",
23]);
24const GENERATED_DIRECTORIES = new Set([
25 "__snapshots__",
26 "__generated__",
27 "dist",
28 "build",
29 "vendor",
30]);
31const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]);
32const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
33
34export function diffFileTier(path: string): DiffFileTier {
35 const segments = path.split("/");
36 const name = segments.at(-1) ?? "";
37 if (
38 GENERATED_FILE_NAMES.has(name) ||
39 name.endsWith(".snap") ||
40 name.endsWith(".min.js") ||
41 name.endsWith(".min.css") ||
42 /\.generated\./.test(name) ||
43 segments.slice(0, -1).some((segment) => GENERATED_DIRECTORIES.has(segment))
44 ) {
45 return "generated";
46 }
47 if (
48 /\.(?:test|spec)\./.test(name) ||
49 segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
50 ) {
51 return "test";
52 }
53 return "source";
54}
55
56function stripExtension(path: string): string {
57 const dot = path.lastIndexOf(".");
58 const slash = path.lastIndexOf("/");
59 return dot > slash ? path.slice(0, dot) : path;
60}
61
62function baseName(path: string): string {
63 return stripExtension(path.split("/").at(-1) ?? "");
64}
65
66/** Resolves `../x` against the importer's directory; no package resolution, only what git shows. */
67function resolveRelative(fromPath: string, specifier: string): string {
68 const segments = fromPath.split("/").slice(0, -1);
69 for (const part of specifier.split("/")) {
70 if (part === "." || part === "") continue;
71 if (part === "..") segments.pop();
72 else segments.push(part);
73 }
74 return segments.join("/");
75}
76
77// `import x from "y"`, `import "y"`, `export … from "y"` and `require("y")` all reduce to a quoted
78// specifier preceded by one of three words.
79const IMPORT_SPECIFIER = /(?:\bfrom|\bimport|\brequire\s*\()\s*\(?\s*["']([^"']+)["']/g;
80
81/** Which changed files a file's patch lines import, by path. */
82function importedPaths(
83 path: string,
84 lines: ReadonlyArray<string>,
85 byModulePath: ReadonlyMap<string, string>,
86 byBaseName: ReadonlyMap<string, string>,
87): ReadonlySet<string> {
88 const imported = new Set<string>();
89 for (const line of lines) {
90 IMPORT_SPECIFIER.lastIndex = 0;
91 let match = IMPORT_SPECIFIER.exec(line);
92 while (match !== null) {
93 const specifier = match[1] ?? "";
94 const withExtension = specifier.startsWith(".")
95 ? resolveRelative(path, specifier)
96 : specifier;
97 // A specifier may name the extension the module map dropped, and it need not be the one on
98 // disk: TypeScript's own imports point at the `.js` beside a `.ts`.
99 const extension = MODULE_EXTENSIONS.find((candidate) => withExtension.endsWith(candidate));
100 const resolved =
101 extension === undefined ? withExtension : withExtension.slice(0, -extension.length);
102 const target =
103 byModulePath.get(resolved) ??
104 byModulePath.get(`${resolved}/index`) ??
105 byBaseName.get(baseName(resolved));
106 if (target !== undefined && target !== path) imported.add(target);
107 match = IMPORT_SPECIFIER.exec(line);
108 }
109 }
110 return imported;
111}
112
113/**
114 * Source files with what they import ahead of what imports them.
115 *
116 * Kahn's, but every step takes the lowest remaining path rather than any ready node, so the same
117 * change always reads the same way and files of one directory stay together. A cycle leaves nothing
118 * ready: the lowest path still standing is taken anyway, which is the alphabetical order the tier
119 * would have had without a graph.
120 */
121function orderByImports(
122 paths: ReadonlyArray<string>,
123 imports: ReadonlyMap<string, ReadonlySet<string>>,
124): Array<string> {
125 const remaining = new Set(paths);
126 const ordered: Array<string> = [];
127 const sorted = [...paths].sort((left, right) => left.localeCompare(right));
128 while (remaining.size > 0) {
129 const candidates = sorted.filter((path) => remaining.has(path));
130 const next =
131 candidates.find((path) =>
132 [...(imports.get(path) ?? [])].every((dependency) => !remaining.has(dependency)),
133 ) ?? candidates[0]!;
134 remaining.delete(next);
135 ordered.push(next);
136 }
137 return ordered;
138}
139
140/** The implementation a test names: `foo.test.ts` and `__tests__/foo.ts` both point at `foo`. */
141function testedBaseName(path: string): string {
142 return (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, "").replace(/\.[^.]+$/, "");
143}
144
145/**
146 * Diff files in reading order: source in dependency order, then the tests that cover it, then
147 * whatever a tool wrote.
148 */
149export function orderDiffFiles(
150 files: ReadonlyArray<FileDiffMetadata>,
151): ReadonlyArray<FileDiffMetadata> {
152 const byPath = new Map<string, FileDiffMetadata>();
153 for (const file of files) byPath.set(resolveFileDiffPath(file), file);
154 const tiers = new Map<string, DiffFileTier>();
155 for (const path of byPath.keys()) tiers.set(path, diffFileTier(path));
156
157 const sourcePaths = [...byPath.keys()].filter((path) => tiers.get(path) === "source");
158 const byModulePath = new Map<string, string>();
159 const ambiguousBaseNames = new Set<string>();
160 const byBaseName = new Map<string, string>();
161 for (const path of sourcePaths) {
162 byModulePath.set(stripExtension(path), path);
163 const base = baseName(path);
164 if (byBaseName.has(base)) ambiguousBaseNames.add(base);
165 byBaseName.set(base, path);
166 }
167 for (const base of ambiguousBaseNames) byBaseName.delete(base);
168
169 const imports = new Map<string, ReadonlySet<string>>();
170 for (const path of sourcePaths) {
171 const file = byPath.get(path)!;
172 imports.set(
173 path,
174 importedPaths(path, [...file.additionLines, ...file.deletionLines], byModulePath, byBaseName),
175 );
176 }
177 const orderedSource = orderByImports(sourcePaths, imports);
178
179 const sourcePositions = new Map<string, number>();
180 orderedSource.forEach((path, index) => {
181 const base = baseName(path);
182 if (!sourcePositions.has(base)) sourcePositions.set(base, index);
183 });
184 const orderedTests = [...byPath.keys()]
185 .filter((path) => tiers.get(path) === "test")
186 .sort((left, right) => {
187 const leftPosition = sourcePositions.get(testedBaseName(left)) ?? Number.MAX_SAFE_INTEGER;
188 const rightPosition = sourcePositions.get(testedBaseName(right)) ?? Number.MAX_SAFE_INTEGER;
189 return leftPosition - rightPosition || left.localeCompare(right);
190 });
191
192 const orderedGenerated = [...byPath.keys()]
193 .filter((path) => tiers.get(path) === "generated")
194 .sort((left, right) => left.localeCompare(right));
195
196 return [...orderedSource, ...orderedTests, ...orderedGenerated].map((path) => byPath.get(path)!);
197}
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts created+20
...@@ -0,0 +1,20 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu";
4
5describe("pull request link context menu", () => {
6 it("offers the copy first and the host's own page after it", () => {
7 expect(pullRequestLinkContextMenuItems("Open on GitHub")).toEqual([
8 { id: "copy-link", label: "Copy link", icon: "copy" },
9 { id: "open-external", label: "Open on GitHub" },
10 ]);
11 });
12
13 it("names every host it knows, and says nothing false about one it does not", () => {
14 expect(openOnHostLabel("github")).toBe("Open on GitHub");
15 expect(openOnHostLabel("gitlab")).toBe("Open on GitLab");
16 expect(openOnHostLabel("bitbucket")).toBe("Open on Bitbucket");
17 expect(openOnHostLabel("azure-devops")).toBe("Open on Azure DevOps");
18 expect(openOnHostLabel("something-else")).toBe("Open on host");
19 });
20});
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts created+68
...@@ -0,0 +1,68 @@
1import type { ContextMenuItem } from "@t3tools/contracts";
2
3import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
4import { readLocalApi } from "~/localApi";
5
6import { toastManager } from "../ui/toast";
7
8export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
9
10/** Named for the host rather than "externally": the point is where you will land. */
11export const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {
12 github: "Open on GitHub",
13 gitlab: "Open on GitLab",
14 bitbucket: "Open on Bitbucket",
15 "azure-devops": "Open on Azure DevOps",
16};
17
18export const openOnHostLabel = (provider: string): string =>
19 OPEN_ON_HOST_LABELS[provider] ?? "Open on host";
20
21/** Copy first: it is the reason to right-click a number rather than click it. */
22export function pullRequestLinkContextMenuItems(
23 openLabel: string,
24): readonly ContextMenuItem<PullRequestLinkContextMenuAction>[] {
25 return [
26 { id: "copy-link", label: "Copy link", icon: "copy" },
27 { id: "open-external", label: openLabel },
28 ];
29}
30
31/**
32 * The right-click on a change request's number. Everywhere else that number is written it is a
33 * link, and the gesture that copies a link is the one hand reaches for — so without this the
34 * platform's own edit menu opens over a control that has nothing to cut, paste or select.
35 *
36 * The host is named by the caller rather than guessed here: the same number belongs to GitHub,
37 * GitLab, Bitbucket or Azure DevOps depending on where it was read, and the `url` the contract
38 * carries is already whichever of them it came from.
39 */
40export async function showPullRequestLinkContextMenu({
41 url,
42 openLabel,
43 position,
44}: {
45 readonly url: string;
46 readonly openLabel: string;
47 readonly position: { readonly x: number; readonly y: number };
48}): Promise<void> {
49 const api = readLocalApi();
50 if (!api) return;
51 let action: PullRequestLinkContextMenuAction | null = null;
52 try {
53 action = await api.contextMenu.show(pullRequestLinkContextMenuItems(openLabel), position);
54 } catch {
55 // A menu that could not be shown has already cost the reader their right-click; there is
56 // nothing to say about it that a second popup would not make worse.
57 return;
58 }
59 try {
60 if (action === "copy-link") await writeTextToClipboard(url, "link");
61 else if (action === "open-external") await api.shell.openExternal(url);
62 } catch {
63 toastManager.add({
64 type: "error",
65 title: action === "copy-link" ? "Could not copy the link" : "Could not open the link",
66 });
67 }
68}
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+574-16
...@@ -1,10 +1,16 @@...@@ -1,10 +1,16 @@
1import type { PullRequestListEntry } from "@t3tools/contracts";1import type { EnvironmentId, PullRequestListEntry } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 filterPullRequestsByInvolvement,5 filterPullRequestsByInvolvement,
6 findScopedProject,
7 mergePullRequestLists,
8 pullRequestEntryKey,
9 pullRequestEnvironmentSetKey,
6 groupPullRequestsByInvolvement,10 groupPullRequestsByInvolvement,
11 matchesPullRequestFilters,
7 matchesPullRequestQuery,12 matchesPullRequestQuery,
13 parsePullRequestQuery,
8 mergePullRequestDiffStats,14 mergePullRequestDiffStats,
9 narrowPullRequestsToFilters,15 narrowPullRequestsToFilters,
10 partitionPullRequestsWithPriority,16 partitionPullRequestsWithPriority,
...@@ -14,13 +20,19 @@ import {...@@ -14,13 +20,19 @@ import {
14 scorePullRequestMatch,20 scorePullRequestMatch,
15 withDiffStat,21 withDiffStat,
16 resolveProjectScope,22 resolveProjectScope,
23 resolveQueryEnvironmentIds,
24 resolveSelectedEnvironmentId,
25 type EnvironmentPullRequestEntry,
17} from "./pullRequestList.logic";26} from "./pullRequestList.logic";
1827
19const VIEWERS = { "github.com": "Bilal" } as const;28const VIEWERS = { "github.com": "Bilal" } as const;
20const NO_VIEWERS = {} as const;29const NO_VIEWERS = {} as const;
2130
22function entry(overrides: Partial<PullRequestListEntry> & Pick<PullRequestListEntry, "number">) {31function entry(
32 overrides: Partial<EnvironmentPullRequestEntry> & Pick<PullRequestListEntry, "number">,
33) {
23 return {34 return {
35 environmentId: "env-1",
24 provider: "github",36 provider: "github",
25 host: "github.com",37 host: "github.com",
26 projectId: "project-1",38 projectId: "project-1",
...@@ -41,7 +53,7 @@ function entry(overrides: Partial<PullRequestListEntry> & Pick<PullRequestListEn...@@ -41,7 +53,7 @@ function entry(overrides: Partial<PullRequestListEntry> & Pick<PullRequestListEn
41 viewerReviewRequested: false,53 viewerReviewRequested: false,
42 labels: [],54 labels: [],
43 ...overrides,55 ...overrides,
44 } as PullRequestListEntry;56 } as EnvironmentPullRequestEntry;
45}57}
4658
47describe("pull request involvement filtering", () => {59describe("pull request involvement filtering", () => {
...@@ -208,6 +220,172 @@ describe("carrying rows already read into filters nothing has answered yet", ()...@@ -208,6 +220,172 @@ describe("carrying rows already read into filters nothing has answered yet", ()
208 });220 });
209});221});
210222
223describe("narrowing rows by the filters a host may not have applied", () => {
224 const rows = [
225 entry({ number: 1 }),
226 entry({ number: 2, isDraft: true }),
227 entry({ number: 3, reviewDecision: "approved" }),
228 entry({
229 number: 4,
230 labels: [{ name: "Needs design", color: null }],
231 author: { login: "Hubot", name: null, avatarUrl: null },
232 reviewDecision: "changes-requested",
233 }),
234 ];
235 const narrow = (filters: Parameters<typeof matchesPullRequestFilters>[1]) =>
236 rows.filter((row) => matchesPullRequestFilters(row, filters)).map((row) => row.number);
237
238 it("keeps or drops drafts as asked", () => {
239 expect(narrow({ draft: "only" })).toEqual([2]);
240 expect(narrow({ draft: "hide" })).toEqual([1, 3, 4]);
241 });
242
243 it("keeps the review state that was asked for, and no reviews means none at all", () => {
244 expect(narrow({ review: "approved" })).toEqual([3]);
245 expect(narrow({ review: "changes-requested" })).toEqual([4]);
246 expect(narrow({ review: "none" })).toEqual([1, 2]);
247 });
248
249 it("matches labels and authors however either was capitalized", () => {
250 expect(narrow({ labels: [["needs DESIGN"]] })).toEqual([4]);
251 expect(narrow({ excludedLabels: ["needs design"] })).toEqual([1, 2, 3]);
252 expect(narrow({ author: "hubot" })).toEqual([4]);
253 });
254
255 it("resolves author:me to the given viewer, case-insensitively", () => {
256 const mine = entry({ number: 30, author: { login: "Bilal", name: null, avatarUrl: null } });
257 const theirs = entry({ number: 31, author: { login: "Hubot", name: null, avatarUrl: null } });
258 expect(matchesPullRequestFilters(mine, { author: "me" }, "bilal")).toBe(true);
259 expect(matchesPullRequestFilters(theirs, { author: "me" }, "bilal")).toBe(false);
260 });
261
262 it("resolves author:@me the same way as author:me", () => {
263 const mine = entry({ number: 32, author: { login: "Bilal", name: null, avatarUrl: null } });
264 const theirs = entry({ number: 33, author: { login: "Hubot", name: null, avatarUrl: null } });
265 expect(matchesPullRequestFilters(mine, { author: "@me" }, "Bilal")).toBe(true);
266 expect(matchesPullRequestFilters(theirs, { author: "@me" }, "Bilal")).toBe(false);
267 });
268
269 it("compares author:me against the literal name when there is no viewer", () => {
270 const literalMe = entry({ number: 34, author: { login: "me", name: null, avatarUrl: null } });
271 const someoneElse = entry({
272 number: 35,
273 author: { login: "Bilal", name: null, avatarUrl: null },
274 });
275 expect(matchesPullRequestFilters(literalMe, { author: "me" })).toBe(true);
276 expect(matchesPullRequestFilters(someoneElse, { author: "me" })).toBe(false);
277 });
278
279 it("takes a group of labels as an either-or", () => {
280 const sized = [
281 entry({ number: 10, labels: [{ name: "size:S", color: null }] }),
282 entry({ number: 11, labels: [{ name: "size:XS", color: null }] }),
283 entry({ number: 12, labels: [{ name: "size:L", color: null }] }),
284 ];
285 const kept = (labels: ReadonlyArray<ReadonlyArray<string>>) =>
286 sized.filter((row) => matchesPullRequestFilters(row, { labels })).map((row) => row.number);
287
288 expect(kept([["size:S", "size:XS"]])).toEqual([10, 11]);
289 expect(kept([["size:XXL"]])).toEqual([]);
290 });
291
292 it("takes two groups as an and, each satisfied on its own", () => {
293 const row = entry({
294 number: 20,
295 labels: [
296 { name: "size:S", color: null },
297 { name: "bug", color: null },
298 ],
299 });
300 expect(matchesPullRequestFilters(row, { labels: [["size:S", "size:XS"], ["bug"]] })).toBe(true);
301 // The second group holds nothing this row carries, so the first one satisfied is not enough.
302 expect(matchesPullRequestFilters(row, { labels: [["size:S"], ["wip"]] })).toBe(false);
303 });
304
305 it("holds on to every row for a filter no row carries the answer to", () => {
306 expect(narrow({ checks: "passing" })).toEqual([1, 2, 3, 4]);
307 });
308});
309
310describe("reading qualifiers out of a typed query", () => {
311 it("keeps quoted values whole and separates negation", () => {
312 expect(parsePullRequestQuery('label:"needs design" -label:wip Fix the parser')).toEqual({
313 text: "Fix the parser",
314 filters: { labels: [["needs design"]], excludedLabels: ["wip"] },
315 });
316 });
317
318 it("reads a comma-separated list as one group either name satisfies", () => {
319 expect(parsePullRequestQuery("label:size:S,size:XS").filters.labels).toEqual([
320 ["size:S", "size:XS"],
321 ]);
322 });
323
324 it("keeps the spaces in a quoted name of a list, and drops an empty part", () => {
325 expect(parsePullRequestQuery('label:"needs design","wip"').filters.labels).toEqual([
326 ["needs design", "wip"],
327 ]);
328 expect(parsePullRequestQuery("label:a,,b").filters.labels).toEqual([["a", "b"]]);
329 });
330
331 it("excludes every name of a negated list", () => {
332 expect(parsePullRequestQuery("-label:a,b").filters).toEqual({ excludedLabels: ["a", "b"] });
333 });
334
335 it("makes each label qualifier its own group, so the groups are an AND", () => {
336 expect(parsePullRequestQuery("label:bug label:a,b").filters.labels).toEqual([
337 ["bug"],
338 ["a", "b"],
339 ]);
340 });
341
342 it("reads the scalar qualifiers in GitHub's own spelling", () => {
343 expect(
344 parsePullRequestQuery("author:octocat DRAFT:false review:changes_requested status:failure")
345 .filters,
346 ).toEqual({
347 author: "octocat",
348 draft: "hide",
349 review: "changes-requested",
350 checks: "failing",
351 });
352 });
353
354 it("leaves an unknown value and a stray colon as text, and reads an unknown key as a label", () => {
355 const parsed = parsePullRequestQuery("milestone:v2 draft:maybe status: parser");
356 expect(parsed.filters).toEqual({ labels: [["milestone:v2"]] });
357 // A known key whose value it does not take is text, so "status:" itself stays findable.
358 expect(parsed.text).toBe("draft:maybe status: parser");
359 });
360
361 it("reads a quoted name holding a comma as the one label it is", () => {
362 expect(parsePullRequestQuery('label:"needs,triage"').filters.labels).toEqual([
363 ["needs,triage"],
364 ]);
365 expect(parsePullRequestQuery('-label:"needs,triage"').filters.excludedLabels).toEqual([
366 "needs,triage",
367 ]);
368 });
369
370 it("cuts a typed query back to what the contract carries", () => {
371 const many = Array.from({ length: 14 }, (_, index) => `l${index}`).join(",");
372 const long = "x".repeat(240);
373 const parsed = parsePullRequestQuery(`label:${many} label:${long} author:${long}`);
374 expect(parsed.filters.labels?.[0]).toHaveLength(10);
375 expect(parsed.filters.labels?.[1]).toEqual(["x".repeat(200)]);
376 expect(parsed.filters.author).toBe("x".repeat(200));
377 });
378
379 it("cuts a namespaced label after its key is prefixed on", () => {
380 const parsed = parsePullRequestQuery(`size:${"x".repeat(240)}`);
381 expect(parsed.filters.labels).toEqual([[`size:${"x".repeat(240)}`.slice(0, 200)]]);
382 });
383
384 it("answers an empty query with an empty everything", () => {
385 expect(parsePullRequestQuery(" ")).toEqual({ text: "", filters: {} });
386 });
387});
388
211describe("resolveProjectScope", () => {389describe("resolveProjectScope", () => {
212 const projects = [{ id: "p1" }, { id: "p2" }];390 const projects = [{ id: "p1" }, { id: "p2" }];
213391
...@@ -283,7 +461,7 @@ describe("ranking what a search found", () => {...@@ -283,7 +461,7 @@ describe("ranking what a search found", () => {
283});461});
284462
285describe("line counts that arrive after the rows", () => {463describe("line counts that arrive after the rows", () => {
286 const stats = new Map([["project-1 7", { additions: 42, deletions: 3 }]]);464 const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]);
287465
288 it("fills in a row whose host left the counts for later", () => {466 it("fills in a row whose host left the counts for later", () => {
289 const row = entry({ number: 7, additions: 0, deletions: 0 });467 const row = entry({ number: 7, additions: 0, deletions: 0 });
...@@ -304,33 +482,33 @@ describe("line counts that arrive after the rows", () => {...@@ -304,33 +482,33 @@ describe("line counts that arrive after the rows", () => {
304describe("merging line counts across keyed stats queries", () => {482describe("merging line counts across keyed stats queries", () => {
305 it("keeps counts already held while a fresh batch says nothing about them", () => {483 it("keeps counts already held while a fresh batch says nothing about them", () => {
306 const held = mergePullRequestDiffStats(new Map(), [484 const held = mergePullRequestDiffStats(new Map(), [
307 { projectId: "project-1", number: 1, additions: 10, deletions: 2 },485 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 },
308 { projectId: "project-1", number: 2, additions: 5, deletions: 1 },486 { environmentId: "env-1", projectId: "project-1", number: 2, additions: 5, deletions: 1 },
309 ]);487 ]);
310 // A third row appeared; its batch is still pending and contributes nothing yet.488 // A third row appeared; its batch is still pending and contributes nothing yet.
311 const merged = mergePullRequestDiffStats(held, []);489 const merged = mergePullRequestDiffStats(held, []);
312 expect(merged.get("project-1 1")).toEqual({ additions: 10, deletions: 2 });490 expect(merged.get("env-1 project-1 1")).toEqual({ additions: 10, deletions: 2 });
313 expect(merged.get("project-1 2")).toEqual({ additions: 5, deletions: 1 });491 expect(merged.get("env-1 project-1 2")).toEqual({ additions: 5, deletions: 1 });
314 });492 });
315493
316 it("replaces a count once its replacement arrives, keeping its neighbours", () => {494 it("replaces a count once its replacement arrives, keeping its neighbours", () => {
317 const held = mergePullRequestDiffStats(new Map(), [495 const held = mergePullRequestDiffStats(new Map(), [
318 { projectId: "project-1", number: 1, additions: 10, deletions: 2 },496 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 },
319 ]);497 ]);
320 const merged = mergePullRequestDiffStats(held, [498 const merged = mergePullRequestDiffStats(held, [
321 { projectId: "project-1", number: 1, additions: 11, deletions: 2 },499 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 11, deletions: 2 },
322 { projectId: "project-1", number: 3, additions: 7, deletions: 0 },500 { environmentId: "env-1", projectId: "project-1", number: 3, additions: 7, deletions: 0 },
323 ]);501 ]);
324 expect(merged.get("project-1 1")).toEqual({ additions: 11, deletions: 2 });502 expect(merged.get("env-1 project-1 1")).toEqual({ additions: 11, deletions: 2 });
325 expect(merged.get("project-1 3")).toEqual({ additions: 7, deletions: 0 });503 expect(merged.get("env-1 project-1 3")).toEqual({ additions: 7, deletions: 0 });
326 });504 });
327505
328 it("does not mutate the map it was handed", () => {506 it("does not mutate the map it was handed", () => {
329 const held = new Map([["project-1 1", { additions: 1, deletions: 1 }]]);507 const held = new Map([["env-1 project-1 1", { additions: 1, deletions: 1 }]]);
330 mergePullRequestDiffStats(held, [508 mergePullRequestDiffStats(held, [
331 { projectId: "project-1", number: 1, additions: 2, deletions: 2 },509 { environmentId: "env-1", projectId: "project-1", number: 1, additions: 2, deletions: 2 },
332 ]);510 ]);
333 expect(held.get("project-1 1")).toEqual({ additions: 1, deletions: 1 });511 expect(held.get("env-1 project-1 1")).toEqual({ additions: 1, deletions: 1 });
334 });512 });
335});513});
336514
...@@ -442,4 +620,384 @@ describe("the list snapshot across a reload", () => {...@@ -442,4 +620,384 @@ describe("the list snapshot across a reload", () => {
442 expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull();620 expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull();
443 expect(readPullRequestListSnapshot(undefined, "env-1")).toBeNull();621 expect(readPullRequestListSnapshot(undefined, "env-1")).toBeNull();
444 });622 });
623
624 it("caps the accumulated rows but keeps the whole host set", () => {
625 // The page keeps growing as the reader scrolls, so what gets written is the accumulated
626 // list rather than one round's answer — capped at the one page a cold start needs, while
627 // the hosts it was read from (which do not grow with the page) are kept in full.
628 const storage = makeStorage();
629 const manyEntries = Array.from({ length: 120 }, (_, index) => entry({ number: index + 1 }));
630 const viewers = { "github.com": "Bilal", "gitlab.com": "Bilal" };
631 const providers = [
632 {
633 host: "github.com",
634 kind: "github",
635 searchesOnHost: true,
636 projectCount: 1,
637 configured: true,
638 detail: null,
639 },
640 {
641 host: "gitlab.com",
642 kind: "gitlab",
643 searchesOnHost: true,
644 projectCount: 1,
645 configured: true,
646 detail: null,
647 },
648 ];
649 const accumulated = {
650 entries: manyEntries,
651 viewers,
652 providers,
653 errors: [],
654 truncated: true,
655 nextCursors: {},
656 } as never;
657 writePullRequestListSnapshot(storage, "env-1", { scope: "s", data: accumulated });
658 const snapshot = readPullRequestListSnapshot(storage, "env-1");
659 expect(snapshot?.data.entries).toHaveLength(99);
660 expect(snapshot?.data.viewers).toEqual(viewers);
661 expect(snapshot?.data.providers).toEqual(providers);
662 });
663});
664
665const ENV_1 = "env-1" as EnvironmentId;
666const ENV_2 = "env-2" as EnvironmentId;
667
668describe("merging the environments' own listings", () => {
669 const answer = (
670 overrides: Partial<Parameters<typeof mergePullRequestLists>[0][number][1]> = {},
671 ) =>
672 ({
673 viewers: { "github.com": "Bilal" },
674 providers: [
675 {
676 host: "github.com",
677 kind: "github",
678 searchesOnHost: true,
679 projectCount: 1,
680 configured: true,
681 detail: null,
682 },
683 ],
684 entries: [],
685 errors: [],
686 truncated: false,
687 nextCursors: {},
688 ...overrides,
689 }) as Parameters<typeof mergePullRequestLists>[0][number][1];
690
691 it("answers nothing until an environment has", () => {
692 expect(mergePullRequestLists([])).toBeNull();
693 });
694
695 it("tags every row with the environment that read it, newest first", () => {
696 const merged = mergePullRequestLists([
697 [ENV_1, answer({ entries: [entry({ number: 1, updatedAt: "2026-07-01T00:00:00Z" })] })],
698 [ENV_2, answer({ entries: [entry({ number: 2, updatedAt: "2026-08-01T00:00:00Z" })] })],
699 ]);
700 expect(merged?.entries.map((row) => [row.environmentId, row.number])).toEqual([
701 [ENV_2, 2],
702 [ENV_1, 1],
703 ]);
704 });
705
706 it("tells two environments' copies of one pull request apart", () => {
707 const row = entry({ number: 4 });
708 expect(pullRequestEntryKey({ ...row, environmentId: ENV_1 })).not.toBe(
709 pullRequestEntryKey({ ...row, environmentId: ENV_2 }),
710 );
711 });
712
713 it("folds a host reached from two environments into one switcher row", () => {
714 const merged = mergePullRequestLists([
715 [ENV_1, answer()],
716 [
717 ENV_2,
718 answer({
719 providers: [
720 {
721 host: "github.com",
722 kind: "github",
723 searchesOnHost: false,
724 projectCount: 2,
725 configured: false,
726 detail: "Not signed in.",
727 },
728 ],
729 }),
730 ],
731 ]);
732 // Readable because one environment could read it, but narrowed locally because the other
733 // answers unsearched.
734 expect(merged?.providers).toEqual([
735 {
736 host: "github.com",
737 kind: "github",
738 searchesOnHost: false,
739 projectCount: 3,
740 configured: true,
741 detail: null,
742 },
743 ]);
744 });
745
746 it("keeps each environment's continuation to itself", () => {
747 const merged = mergePullRequestLists([
748 [ENV_1, answer({ nextCursors: { "github.com pingdotgg/t3code": "cursor-1" } })],
749 [ENV_2, answer()],
750 ]);
751 expect(merged?.nextCursors).toEqual({
752 [ENV_1]: { "github.com pingdotgg/t3code": "cursor-1" },
753 });
754 });
755
756 it("reads the same key whichever order the environments connected in", () => {
757 expect(pullRequestEnvironmentSetKey(["env-2", "env-1"])).toBe(
758 pullRequestEnvironmentSetKey(["env-1", "env-2"]),
759 );
760 });
761});
762
763describe('who "I" am, per server', () => {
764 const answer = (viewers: Record<string, string>, entries: ReadonlyArray<PullRequestListEntry>) =>
765 ({
766 viewers,
767 providers: [],
768 entries,
769 errors: [],
770 truncated: false,
771 nextCursors: {},
772 }) as Parameters<typeof mergePullRequestLists>[0][number][1];
773 const byBilal = { login: "Bilal", name: null, avatarUrl: null };
774
775 it("does not let one server's account decide who authored another server's rows", () => {
776 // Both servers reach github.com, signed in as different people. Folded into one host-keyed
777 // record, whichever answered last spoke for both — and every row of the reader's own work
778 // was filed under Others.
779 const merged = mergePullRequestLists([
780 [ENV_1, answer({ "github.com": "Bilal" }, [entry({ number: 1, author: byBilal })])],
781 [ENV_2, answer({ "github.com": "Octocat" }, [entry({ number: 2, author: byBilal })])],
782 ])!;
783
784 const groups = groupPullRequestsByInvolvement(merged.entries, merged.viewers);
785 expect(
786 groups.find((group) => group.key === "authored")?.entries.map((row) => row.number),
787 ).toEqual([1]);
788 expect(
789 filterPullRequestsByInvolvement(merged.entries, merged.viewers, "authored").map(
790 (row) => row.number,
791 ),
792 ).toEqual([1]);
793 });
794
795 it("still reads a single server's host-keyed viewers, which is what a snapshot carries", () => {
796 expect(
797 filterPullRequestsByInvolvement(
798 [entry({ number: 1, author: byBilal })],
799 { "github.com": "Bilal" },
800 "authored",
801 ).map((row) => row.number),
802 ).toEqual([1]);
803 });
804
805 it("names the servers with more rows and no cursor to reach them by", () => {
806 const merged = mergePullRequestLists([
807 [
808 ENV_1,
809 { ...answer({}, []), truncated: true, nextCursors: { "github.com acme/web": "cursor-1" } },
810 ],
811 [ENV_2, { ...answer({}, []), truncated: true }],
812 ])!;
813
814 expect(merged.truncatedEnvironments).toEqual([ENV_1, ENV_2]);
815 expect(Object.keys(merged.nextCursors)).toEqual([ENV_1]);
816 });
817});
818
819describe("the project an id names", () => {
820 const projects = [
821 { id: "project-1", environmentId: ENV_1 },
822 { id: "project-1", environmentId: ENV_2 },
823 { id: "project-2", environmentId: ENV_2 },
824 ];
825
826 it("takes the server it was given", () => {
827 expect(findScopedProject(projects, ENV_2, "project-1")?.environmentId).toBe(ENV_2);
828 });
829
830 it("answers a bare id only where one server has it", () => {
831 expect(findScopedProject(projects, null, "project-2")?.environmentId).toBe(ENV_2);
832 // Two servers hold this id: narrowing to either would be a coin toss the reader cannot see.
833 expect(findScopedProject(projects, null, "project-1")).toBeUndefined();
834 });
835
836 it("answers nothing for a server that does not have it", () => {
837 expect(findScopedProject(projects, ENV_1, "project-2")).toBeUndefined();
838 });
839});
840
841describe("which environments a listing should ask", () => {
842 const ENV_3 = "env-3" as EnvironmentId;
843 const environmentIds = [ENV_1, ENV_2, ENV_3];
844
845 it("asks only the owning server once the project is unambiguous", () => {
846 const projects = [{ id: "project-1", environmentId: ENV_2 }];
847 expect(
848 resolveQueryEnvironmentIds(
849 environmentIds,
850 projects,
851 { environmentId: ENV_2 },
852 "project-1",
853 true,
854 ),
855 ).toEqual([ENV_2]);
856 });
857
858 it("asks every environment when there is no project narrowing at all", () => {
859 expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, undefined, true)).toEqual(
860 environmentIds,
861 );
862 });
863
864 it("asks only the environments that actually hold an ambiguous bare id, never a third that doesn't", () => {
865 const projects = [
866 { id: "project-1", environmentId: ENV_1 },
867 { id: "project-1", environmentId: ENV_2 },
868 ];
869 // Two servers can genuinely hold the same project id string; the third holds no such project
870 // and asking it would return an unrelated project's rows rather than an honest empty answer.
871 expect(
872 resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-1", true),
873 ).toEqual([ENV_1, ENV_2]);
874 });
875
876 it("asks nothing for a bare id no environment holds", () => {
877 const projects = [{ id: "project-1", environmentId: ENV_1 }];
878 expect(
879 resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-9", true),
880 ).toEqual([]);
881 });
882
883 it("asks every environment while the servers have not said what they hold yet", () => {
884 // Nothing matches the id because nothing has been read yet, which is not the same answer as
885 // no server holding it: reading none of them would show an empty page for a project that is
886 // there.
887 expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, "project-1", false)).toEqual(
888 environmentIds,
889 );
890 });
891
892 it("keeps the single-environment case unchanged", () => {
893 const projects = [{ id: "project-1", environmentId: ENV_1 }];
894 expect(resolveQueryEnvironmentIds([ENV_1], projects, undefined, "project-1", true)).toEqual([
895 ENV_1,
896 ]);
897 });
898});
899
900describe("the server a saved selection names", () => {
901 const known = new Set([ENV_1, ENV_2]);
902
903 it("resolves to nothing yet for a server that is known but not ready, rather than falling back", () => {
904 // ENV_2 is in the catalog (still connecting, say) but not the fallback; a caller that then
905 // looks a project up under ENV_2 finds none rather than one belonging to the fallback server.
906 expect(resolveSelectedEnvironmentId(ENV_2, known, ENV_1)).toBe(ENV_2);
907 });
908
909 it("falls back for a server the workspace genuinely no longer has", () => {
910 const goneServer = "env-gone" as EnvironmentId;
911 expect(resolveSelectedEnvironmentId(goneServer, known, ENV_1)).toBe(ENV_1);
912 });
913
914 it("falls back to nothing when no server names a fallback either", () => {
915 const goneServer = "env-gone" as EnvironmentId;
916 expect(resolveSelectedEnvironmentId(goneServer, known, null)).toBeNull();
917 });
918
919 it("uses the fallback outright when nothing was named", () => {
920 expect(resolveSelectedEnvironmentId(undefined, known, ENV_1)).toBe(ENV_1);
921 expect(resolveSelectedEnvironmentId(undefined, known, null)).toBeNull();
922 });
923});
924
925describe("colon-namespaced labels typed as a search", () => {
926 it("reads an unknown key as the label it almost always is", () => {
927 expect(parsePullRequestQuery("size:XXL").filters.labels).toEqual([["size:XXL"]]);
928 expect(parsePullRequestQuery("vouch:trusted").filters.labels).toEqual([["vouch:trusted"]]);
929 expect(parsePullRequestQuery("size:XXL").text).toBe("");
930 });
931
932 it("reads the key as the namespace of every bare name in its list", () => {
933 expect(parsePullRequestQuery("size:S,XS").filters.labels).toEqual([["size:S", "size:XS"]]);
934 });
935
936 it("leaves a name that already carries its own namespace alone", () => {
937 // `size:S,size:XS` is the same pair written out; prefixing again would ask for `size:size:XS`.
938 expect(parsePullRequestQuery("size:S,size:XS").filters.labels).toEqual([["size:S", "size:XS"]]);
939 });
940
941 it("excludes one the same way", () => {
942 expect(parsePullRequestQuery("-size:XXL").filters.excludedLabels).toEqual(["size:XXL"]);
943 expect(parsePullRequestQuery("-size:S,XS").filters.excludedLabels).toEqual([
944 "size:S",
945 "size:XS",
946 ]);
947 });
948
949 it("keeps a quoted token as text, which is the way back to a literal search", () => {
950 const parsed = parsePullRequestQuery('"size:XXL"');
951 expect(parsed.filters.labels).toBeUndefined();
952 expect(parsed.text).toBe('"size:XXL"');
953 });
954
955 it("reads a hyphenated namespace as a label too", () => {
956 expect(parsePullRequestQuery("priority-1:high").filters.labels).toEqual([["priority-1:high"]]);
957 expect(parsePullRequestQuery("priority-1:high").text).toBe("");
958 });
959
960 it("leaves a pasted link alone rather than naming a label after its scheme", () => {
961 const parsed = parsePullRequestQuery("https://github.com/pingdotgg/t3code/pull/1");
962 expect(parsed.filters.labels).toBeUndefined();
963 expect(parsed.text).toBe("https://github.com/pingdotgg/t3code/pull/1");
964 });
965
966 it("mixes with the keys it does know, and with plain words", () => {
967 const parsed = parsePullRequestQuery("area:web draft:true wizard label:bug");
968 expect(parsed.filters).toEqual({ labels: [["area:web"], ["bug"]], draft: "only" });
969 expect(parsed.text).toBe("wizard");
970 });
971
972 it("shares the ceiling with the labels typed as labels", () => {
973 const many = Array.from({ length: 12 }, (_, index) => `area:${index}`).join(" ");
974 expect(parsePullRequestQuery(many).filters.labels).toHaveLength(10);
975 });
976});
977
978describe("the priority groups against a paginated feed", () => {
979 const authoredRow = (number: number, updatedAt: string) =>
980 entry({ number, updatedAt, author: { login: "Bilal", name: null, avatarUrl: null } });
981
982 it("shows every authored row the host reported, not only the ones the feed page holds", () => {
983 // The feed is one page ordered by recency, so on a busy repository it can hold exactly one of
984 // somebody's own pull requests while the rest sit further down the host's list. The Authored
985 // group comes from its own server-filtered read for that reason: grouping the page instead
986 // leaves the older ones under Others, or off the page altogether.
987 const newest = authoredRow(6039, "2026-08-11T08:00:00Z");
988 const older = [
989 authoredRow(5499, "2026-08-10T17:00:00Z"),
990 authoredRow(5544, "2026-08-10T16:00:00Z"),
991 ];
992 const feed = [newest, entry({ number: 6123, updatedAt: "2026-08-11T09:00:00Z" })];
993
994 const groups = partitionPullRequestsWithPriority(feed, [newest, ...older], []);
995
996 expect(
997 groups.find((group) => group.key === "authored")?.entries.map((row) => row.number),
998 ).toEqual([6039, 5499, 5544]);
999 expect(
1000 groups.find((group) => group.key === "others")?.entries.map((row) => row.number),
1001 ).toEqual([6123]);
1002 });
445});1003});
apps/web/src/components/pullRequest/pullRequestList.logic.ts+457-50
...@@ -1,19 +1,54 @@...@@ -1,19 +1,54 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import { PullRequestListEntry, PullRequestListResult } from "@t3tools/contracts";3import {
4import type { PullRequestInvolvement, PullRequestListState } from "@t3tools/contracts";4 EnvironmentId,
5 PullRequestListEntry,
6 PullRequestListResult,
7 resolvePullRequestAuthorFilter,
8} from "@t3tools/contracts";
9import type {
10 PullRequestDiffStat,
11 PullRequestInvolvement,
12 PullRequestListCursors,
13 PullRequestListFilters,
14 PullRequestListState,
15} from "@t3tools/contracts";
16
17/**
18 * A listed change request with the environment that read it. Nothing on a row says which machine
19 * it came from, and the page unions every connected one — so acting on a row, refreshing it, or
20 * opening its detail all need the tag the listing itself does not carry.
21 */
22export interface EnvironmentPullRequestEntry extends PullRequestListEntry {
23 readonly environmentId: EnvironmentId;
24}
25
26export interface EnvironmentPullRequestStat extends PullRequestDiffStat {
27 readonly environmentId: EnvironmentId;
28}
529
6export type PullRequestGroupKey = "reviewRequested" | "authored" | "others";30export type PullRequestGroupKey = "reviewRequested" | "authored" | "others";
731
8export interface PullRequestGroup {32export interface PullRequestGroup<Entry extends PullRequestListEntry = PullRequestListEntry> {
9 readonly key: PullRequestGroupKey;33 readonly key: PullRequestGroupKey;
10 readonly label: string;34 readonly label: string;
11 readonly entries: ReadonlyArray<PullRequestListEntry>;35 readonly entries: ReadonlyArray<Entry>;
12}36}
1337
14/** The signed-in account per host, as the listing reports it. */38/**
39 * The signed-in account per host. Keyed `"<environmentId> <host>"` once a listing spans more than
40 * one environment: two machines can both reach github.com signed in as different people, and a
41 * single host-keyed record would let whichever answered last decide who "I" am — which is how
42 * every row of somebody's own work came to be filed under Others.
43 */
15export type PullRequestViewers = PullRequestListResult["viewers"];44export type PullRequestViewers = PullRequestListResult["viewers"];
1645
46/** A row plus the environment that read it, where the caller has one to give. */
47type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string };
48
49export const pullRequestViewerKey = (entry: ScopedEntry): string =>
50 `${entry.environmentId ?? ""} ${entry.host}`;
51
17const GROUP_LABELS: Record<PullRequestGroupKey, string> = {52const GROUP_LABELS: Record<PullRequestGroupKey, string> = {
18 reviewRequested: "Review requested",53 reviewRequested: "Review requested",
19 authored: "Authored",54 authored: "Authored",
...@@ -25,16 +60,181 @@ function normalize(value: string | null | undefined): string | null {...@@ -25,16 +60,181 @@ function normalize(value: string | null | undefined): string | null {
25 return trimmed.length > 0 ? trimmed : null;60 return trimmed.length > 0 ? trimmed : null;
26}61}
2762
63/**
64 * The signed-in login for the host a row came from, or null where none was given. Shared by
65 * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed.
66 */
67export function pullRequestEntryViewer(
68 entry: ScopedEntry,
69 viewers: PullRequestViewers,
70): string | null {
71 // The environment's own answer first; a plain host key is what a single-environment listing
72 // still writes, and what the snapshot from one carries.
73 return normalize(viewers[pullRequestViewerKey(entry)] ?? viewers[entry.host]);
74}
75
28/**76/**
29 * Authorship is per host, not per provider kind: the same list can hold change requests from77 * Authorship is per host, not per provider kind: the same list can hold change requests from
30 * GitHub, GitLab and a GitHub Enterprise install, and the account that owns one says nothing78 * GitHub, GitLab and a GitHub Enterprise install, and the account that owns one says nothing
31 * about the others.79 * about the others.
32 */80 */
33function isAuthoredByViewer(entry: PullRequestListEntry, viewers: PullRequestViewers): boolean {81function isAuthoredByViewer(entry: ScopedEntry, viewers: PullRequestViewers): boolean {
34 const viewer = normalize(viewers[entry.host]);82 const viewer = pullRequestEntryViewer(entry, viewers);
35 return viewer !== null && normalize(entry.author?.login) === viewer;83 return viewer !== null && normalize(entry.author?.login) === viewer;
36}84}
3785
86/** What `review:` and `status:` take, in GitHub's spelling and in the contract's. */
87const REVIEW_VALUES: Record<string, PullRequestListFilters["review"]> = {
88 approved: "approved",
89 changes_requested: "changes-requested",
90 "changes-requested": "changes-requested",
91 required: "review-required",
92 "review-required": "review-required",
93 none: "none",
94};
95const CHECKS_VALUES: Record<string, PullRequestListFilters["checks"]> = {
96 success: "passing",
97 passing: "passing",
98 failure: "failing",
99 failing: "failing",
100};
101
102/**
103 * One token of a typed query: a run of non-space characters in which a quoted stretch counts as
104 * part of the token, so `label:"needs design"` stays whole. An unbalanced quote is dropped rather
105 * than swallowing the rest of the line.
106 */
107const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g;
108/** The contract's own ceilings on a qualifier: this many names, each this long. */
109const MAX_QUALIFIER_VALUES = 10;
110const MAX_QUALIFIER_LENGTH = 200;
111
112function qualifierValue(raw: string): string {
113 return raw.replaceAll('"', "").trim();
114}
115
116/** A qualifier's value as the list it may be: split on commas, each name unquoted on its own. */
117function splitQualifierList(raw: string): string[] {
118 // Quoting names one whole label however many commas it holds: `label:"needs,triage"` asks for
119 // the label written that way, not for either half of it.
120 if (/^\s*"[^"]*"\s*$/.test(raw)) {
121 const whole = qualifierValue(raw);
122 return whole.length === 0 ? [] : [whole];
123 }
124 return raw
125 .split(",")
126 .map((part) => qualifierValue(part))
127 .filter((part) => part.length > 0);
128}
129
130/**
131 * A typed query is written by hand, and the contract bounds what a qualifier may carry — so what
132 * is typed past those bounds is cut here rather than refused by the request that carries it.
133 */
134function boundedNames(names: ReadonlyArray<string>): string[] {
135 return names
136 .slice(0, MAX_QUALIFIER_VALUES)
137 .map((name) => name.slice(0, MAX_QUALIFIER_LENGTH).trim())
138 .filter((name) => name.length > 0);
139}
140
141/**
142 * A typed query split into the qualifiers the hosts can act on and the text that is left. Written
143 * GitHub's way — `label:foo`, `-label:"needs design"`, `author:octocat`, `draft:true`,
144 * `review:approved`, `status:success` — because a project's labels are its own and a menu cannot
145 * list them.
146 *
147 * A key this does not know is read as a label of that whole name: repositories namespace their
148 * labels with a colon — `size:XXL`, `area:web`, `vouch:trusted` — and someone typing one means
149 * the label, not a description that happens to contain it. `-size:XXL` excludes the same way.
150 *
151 * Quoting is the way back to plain text: `"size:XXL"` is searched for as written, which is what
152 * makes a literal search for a colon still possible. A known key whose value it does not take is
153 * text too, so a search for "status:" itself is still findable.
154 */
155export function parsePullRequestQuery(raw: string): {
156 readonly text: string;
157 readonly filters: PullRequestListFilters;
158} {
159 const text: string[] = [];
160 const labels: string[][] = [];
161 const excludedLabels: string[] = [];
162 let author: string | undefined;
163 let draft: PullRequestListFilters["draft"];
164 let review: PullRequestListFilters["review"];
165 let checks: PullRequestListFilters["checks"];
166 for (const [token] of raw.matchAll(QUERY_TOKEN)) {
167 const qualifier = /^(-?)([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(token);
168 const value = qualifier === null ? "" : qualifierValue(qualifier[3] ?? "");
169 const negated = qualifier?.[1] === "-";
170 switch (value.length === 0 ? "" : (qualifier?.[2]?.toLowerCase() ?? "")) {
171 case "label": {
172 // GitHub's own OR: `label:a,b` is one qualifier satisfied by either name. Negated, the
173 // comma excludes each — a row carrying any of them goes.
174 const names = boundedNames(splitQualifierList(qualifier?.[3] ?? ""));
175 if (names.length === 0) break;
176 if (negated) excludedLabels.push(...names);
177 else labels.push(names);
178 continue;
179 }
180 case "author":
181 if (negated) break;
182 author = value.slice(0, MAX_QUALIFIER_LENGTH).trim();
183 continue;
184 case "draft":
185 if (negated || (value.toLowerCase() !== "true" && value.toLowerCase() !== "false")) break;
186 draft = value.toLowerCase() === "true" ? "only" : "hide";
187 continue;
188 case "review": {
189 const decision = negated ? undefined : REVIEW_VALUES[value.toLowerCase()];
190 if (decision === undefined) break;
191 review = decision;
192 continue;
193 }
194 case "status":
195 case "checks": {
196 const state = negated ? undefined : CHECKS_VALUES[value.toLowerCase()];
197 if (state === undefined) break;
198 checks = state;
199 continue;
200 }
201 case "":
202 break;
203 default:
204 // An unknown key, read as the namespaced label it almost always is. A pasted link is
205 // not one — `https://…` would otherwise become a label named after its own scheme.
206 if (!value.startsWith("/")) {
207 // The key names the namespace, so the bare parts of `size:S,XS` are both sizes. A part
208 // that already carries a colon names its whole label — `size:S,size:XS` is the same
209 // pair written out, and prefixing it again would ask for `size:size:XS`.
210 const names = boundedNames(
211 splitQualifierList(qualifier?.[3] ?? "").map((name) =>
212 name.includes(":") ? name : `${qualifier?.[2] ?? ""}:${name}`,
213 ),
214 );
215 if (names.length === 0) break;
216 if (negated) excludedLabels.push(...names);
217 else labels.push(names);
218 continue;
219 }
220 }
221 text.push(token);
222 }
223 return {
224 text: text.join(" "),
225 filters: {
226 ...(labels.length === 0 ? {} : { labels: labels.slice(0, MAX_QUALIFIER_VALUES) }),
227 ...(excludedLabels.length === 0
228 ? {}
229 : { excludedLabels: excludedLabels.slice(0, MAX_QUALIFIER_VALUES) }),
230 ...(author === undefined ? {} : { author }),
231 ...(draft === undefined ? {} : { draft }),
232 ...(review === undefined ? {} : { review }),
233 ...(checks === undefined ? {} : { checks }),
234 },
235 };
236}
237
38/** Free-text filter over the fields a row actually shows, plus `#123` / `123`. */238/** Free-text filter over the fields a row actually shows, plus `#123` / `123`. */
39export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean {239export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean {
40 const normalizedQuery = query.trim().toLowerCase();240 const normalizedQuery = query.trim().toLowerCase();
...@@ -48,11 +248,11 @@ export function matchesPullRequestQuery(entry: PullRequestListEntry, query: stri...@@ -48,11 +248,11 @@ export function matchesPullRequestQuery(entry: PullRequestListEntry, query: stri
48 * The server returns the involvement superset for a state, so switching between the Reviewing248 * The server returns the involvement superset for a state, so switching between the Reviewing
49 * and Authored tabs never waits on the network.249 * and Authored tabs never waits on the network.
50 */250 */
51export function filterPullRequestsByInvolvement(251export function filterPullRequestsByInvolvement<Entry extends ScopedEntry>(
52 entries: ReadonlyArray<PullRequestListEntry>,252 entries: ReadonlyArray<Entry>,
53 viewers: PullRequestViewers,253 viewers: PullRequestViewers,
54 involvement: PullRequestInvolvement,254 involvement: PullRequestInvolvement,
55): ReadonlyArray<PullRequestListEntry> {255): ReadonlyArray<Entry> {
56 if (involvement === "reviewing") {256 if (involvement === "reviewing") {
57 return entries.filter((entry) => entry.viewerReviewRequested);257 return entries.filter((entry) => entry.viewerReviewRequested);
58 }258 }
...@@ -75,14 +275,14 @@ export function filterPullRequestsByInvolvement(...@@ -75,14 +275,14 @@ export function filterPullRequestsByInvolvement(
75 * it needs to know who is signed in on each host, and the search text because searching is the275 * it needs to know who is signed in on each host, and the search text because searching is the
76 * hosts' own answer; both are narrowed where that knowledge already lives.276 * hosts' own answer; both are narrowed where that knowledge already lives.
77 */277 */
78export function narrowPullRequestsToFilters(278export function narrowPullRequestsToFilters<Entry extends PullRequestListEntry>(
79 entries: ReadonlyArray<PullRequestListEntry>,279 entries: ReadonlyArray<Entry>,
80 filters: {280 filters: {
81 readonly state: PullRequestListState;281 readonly state: PullRequestListState;
82 readonly projectId: string | undefined;282 readonly projectId: string | undefined;
83 readonly host: string | undefined;283 readonly host: string | undefined;
84 },284 },
85): ReadonlyArray<PullRequestListEntry> {285): ReadonlyArray<Entry> {
86 return entries.filter(286 return entries.filter(
87 (entry) =>287 (entry) =>
88 (filters.state === "all" || entry.state === filters.state) &&288 (filters.state === "all" || entry.state === filters.state) &&
...@@ -91,15 +291,46 @@ export function narrowPullRequestsToFilters(...@@ -91,15 +291,46 @@ export function narrowPullRequestsToFilters(
91 );291 );
92}292}
93293
294/**
295 * The further narrowings over rows that have already arrived, for the hosts that could not apply
296 * them themselves and for the moment before an answer that did lands.
297 *
298 * `checks` is absent because no listed row carries its check state: that one filter is the
299 * host's alone, and a row a host did not narrow stays rather than being guessed at.
300 *
301 * `viewer` is the row's own host's signed-in login, so `author:me` resolves against it rather
302 * than being compared as the literal name "me". Optional, and left unresolved without one.
303 */
304export function matchesPullRequestFilters(
305 entry: PullRequestListEntry,
306 filters: PullRequestListFilters,
307 viewer?: string | null,
308): boolean {
309 const labels = entry.labels.map((label) => label.name.trim().toLowerCase());
310 const holds = (label: string) => labels.includes(label.trim().toLowerCase());
311 return (
312 (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) &&
313 (filters.review === undefined ||
314 (filters.review === "none"
315 ? entry.reviewDecision === undefined
316 : entry.reviewDecision === filters.review)) &&
317 (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&
318 (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&
319 (filters.author === undefined ||
320 entry.author?.login.toLowerCase() ===
321 resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase())
322 );
323}
324
94/**325/**
95 * Only relationships the list data actually carries: no "previously reviewed" bucket is326 * Only relationships the list data actually carries: no "previously reviewed" bucket is
96 * inferred, because the listing has no review history.327 * inferred, because the listing has no review history.
97 */328 */
98export function groupPullRequestsByInvolvement(329export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>(
99 entries: ReadonlyArray<PullRequestListEntry>,330 entries: ReadonlyArray<Entry>,
100 viewers: PullRequestViewers,331 viewers: PullRequestViewers,
101): ReadonlyArray<PullRequestGroup> {332): ReadonlyArray<PullRequestGroup<Entry>> {
102 const buckets: Record<PullRequestGroupKey, PullRequestListEntry[]> = {333 const buckets: Record<PullRequestGroupKey, Entry[]> = {
103 reviewRequested: [],334 reviewRequested: [],
104 authored: [],335 authored: [],
105 others: [],336 others: [],
...@@ -118,9 +349,14 @@ export function groupPullRequestsByInvolvement(...@@ -118,9 +349,14 @@ export function groupPullRequestsByInvolvement(
118 .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));349 .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] }));
119}350}
120351
121/** Repository plus number is unique on one host, so the host makes the key unique overall. */352/**
122export function pullRequestEntryKey(entry: PullRequestListEntry): string {353 * Repository plus number is unique on one host, so the host makes the key unique overall — and
123 return `${entry.host}:${entry.repository}#${entry.number}`;354 * the environment on top of that, because two connected machines can hold the same repository and
355 * would otherwise contribute two rows under one key.
356 */
357export function pullRequestEntryKey(entry: ScopedEntry): string {
358 const scope = entry.environmentId === undefined ? "" : `${entry.environmentId}:`;
359 return `${scope}${entry.host}:${entry.repository}#${entry.number}`;
124}360}
125361
126/**362/**
...@@ -131,11 +367,11 @@ export function pullRequestEntryKey(entry: PullRequestListEntry): string {...@@ -131,11 +367,11 @@ export function pullRequestEntryKey(entry: PullRequestListEntry): string {
131 * server-filtered reads, the feed fills "Others" in its own order, and a continuation can only367 * server-filtered reads, the feed fills "Others" in its own order, and a continuation can only
132 * append — a row it carries that a partition already holds is dropped rather than moved.368 * append — a row it carries that a partition already holds is dropped rather than moved.
133 */369 */
134export function partitionPullRequestsWithPriority(370export function partitionPullRequestsWithPriority<Entry extends PullRequestListEntry>(
135 entries: ReadonlyArray<PullRequestListEntry>,371 entries: ReadonlyArray<Entry>,
136 authored: ReadonlyArray<PullRequestListEntry>,372 authored: ReadonlyArray<Entry>,
137 reviewRequested: ReadonlyArray<PullRequestListEntry>,373 reviewRequested: ReadonlyArray<Entry>,
138): ReadonlyArray<PullRequestGroup> {374): ReadonlyArray<PullRequestGroup<Entry>> {
139 const authoredByKey = new Map(authored.map((entry) => [pullRequestEntryKey(entry), entry]));375 const authoredByKey = new Map(authored.map((entry) => [pullRequestEntryKey(entry), entry]));
140 // A row can be both authored and review-requested; authored wins, as the local grouping has it.376 // A row can be both authored and review-requested; authored wins, as the local grouping has it.
141 const reviewByKey = new Map(377 const reviewByKey = new Map(
...@@ -144,7 +380,7 @@ export function partitionPullRequestsWithPriority(...@@ -144,7 +380,7 @@ export function partitionPullRequestsWithPriority(
144 return authoredByKey.has(key) ? [] : [[key, entry] as const];380 return authoredByKey.has(key) ? [] : [[key, entry] as const];
145 }),381 }),
146 );382 );
147 const others: PullRequestListEntry[] = [];383 const others: Entry[] = [];
148 for (const entry of entries) {384 for (const entry of entries) {
149 const key = pullRequestEntryKey(entry);385 const key = pullRequestEntryKey(entry);
150 // The feed's copy of a partitioned row is at least as fresh — it replaces in place.386 // The feed's copy of a partitioned row is at least as fresh — it replaces in place.
...@@ -156,8 +392,7 @@ export function partitionPullRequestsWithPriority(...@@ -156,8 +392,7 @@ export function partitionPullRequestsWithPriority(
156 others.push(entry);392 others.push(entry);
157 }393 }
158 }394 }
159 const byRecency = (left: PullRequestListEntry, right: PullRequestListEntry) =>395 const byRecency = (left: Entry, right: Entry) => right.updatedAt.localeCompare(left.updatedAt);
160 right.updatedAt.localeCompare(left.updatedAt);
161 return (396 return (
162 [397 [
163 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },398 { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) },
...@@ -183,6 +418,7 @@ export type PullRequestDiffStats = ReadonlyMap<...@@ -183,6 +418,7 @@ export type PullRequestDiffStats = ReadonlyMap<
183export function mergePullRequestDiffStats(418export function mergePullRequestDiffStats(
184 previous: PullRequestDiffStats,419 previous: PullRequestDiffStats,
185 stats: ReadonlyArray<{420 stats: ReadonlyArray<{
421 readonly environmentId: string;
186 readonly projectId: string;422 readonly projectId: string;
187 readonly number: number;423 readonly number: number;
188 readonly additions: number;424 readonly additions: number;
...@@ -192,20 +428,109 @@ export function mergePullRequestDiffStats(...@@ -192,20 +428,109 @@ export function mergePullRequestDiffStats(
192 if (stats.length === 0) return previous;428 if (stats.length === 0) return previous;
193 const next = new Map(previous);429 const next = new Map(previous);
194 for (const stat of stats) {430 for (const stat of stats) {
195 next.set(`${stat.projectId} ${stat.number}`, {431 next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions });
196 additions: stat.additions,
197 deletions: stat.deletions,
198 });
199 }432 }
200 return next;433 return next;
201}434}
202435
436/** A project id only names a project within its own environment, so the key carries both. */
437const diffStatKey = (row: {
438 readonly environmentId: string;
439 readonly projectId: string;
440 readonly number: number;
441}) => `${row.environmentId} ${row.projectId} ${row.number}`;
442
443/**
444 * Every connected environment's listing, read as one list.
445 *
446 * `nextCursors` is keyed by environment rather than by repository: a cursor only means anything
447 * to the host that issued it, and two machines can hold the same repository. `viewers` is not —
448 * a host names one account, and the same host reached from two machines is the same account.
449 */
450export interface MergedPullRequestList {
451 /** Keyed `"<environmentId> <host>"`, so one host's two accounts stay two accounts. */
452 readonly viewers: PullRequestViewers;
453 readonly providers: PullRequestListResult["providers"];
454 readonly entries: ReadonlyArray<EnvironmentPullRequestEntry>;
455 readonly errors: PullRequestListResult["errors"];
456 readonly truncated: boolean;
457 readonly nextCursors: Readonly<Record<string, PullRequestListCursors>>;
458 /**
459 * The environments with rows still on their hosts. Those with a cursor are continued from it;
460 * the rest can only be reached by asking them for a longer page, and are named here so that
461 * asking still happens once every other environment has run out of cursors.
462 */
463 readonly truncatedEnvironments: ReadonlyArray<string>;
464}
465
466/**
467 * The environments' answers folded into one. A host reached from more than one environment is one
468 * row in the switcher, readable if any environment could read it and searched on the host only if
469 * every one of them did — a host answering unnarrowed anywhere still needs the local pass.
470 */
471export function mergePullRequestLists(
472 answers: ReadonlyArray<readonly [EnvironmentId, PullRequestListResult]>,
473): MergedPullRequestList | null {
474 if (answers.length === 0) return null;
475 const viewers: Record<string, string> = {};
476 const truncatedEnvironments: string[] = [];
477 const providers = new Map<string, PullRequestListResult["providers"][number]>();
478 const entries: EnvironmentPullRequestEntry[] = [];
479 const errors: Array<PullRequestListResult["errors"][number]> = [];
480 const nextCursors: Record<string, PullRequestListCursors> = {};
481 let truncated = false;
482 for (const [environmentId, answer] of answers) {
483 for (const [host, login] of Object.entries(answer.viewers)) {
484 viewers[`${environmentId} ${host}`] = login;
485 }
486 for (const provider of answer.providers) {
487 const held = providers.get(provider.host);
488 providers.set(
489 provider.host,
490 held === undefined
491 ? provider
492 : {
493 ...(held.configured ? held : provider),
494 projectCount: held.projectCount + provider.projectCount,
495 searchesOnHost: held.searchesOnHost && provider.searchesOnHost,
496 configured: held.configured || provider.configured,
497 },
498 );
499 }
500 entries.push(...answer.entries.map((entry) => ({ ...entry, environmentId })));
501 errors.push(...answer.errors);
502 truncated ||= answer.truncated;
503 if (answer.truncated) truncatedEnvironments.push(environmentId);
504 if (Object.keys(answer.nextCursors).length > 0) {
505 nextCursors[environmentId] = answer.nextCursors;
506 }
507 }
508 return {
509 viewers,
510 providers: [...providers.values()],
511 entries: entries.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
512 errors,
513 truncated,
514 nextCursors,
515 truncatedEnvironments,
516 };
517}
518
203/** One page is what the list itself starts with, and all a cold start needs to look warm. */519/** One page is what the list itself starts with, and all a cold start needs to look warm. */
204const SNAPSHOT_MAX_ENTRIES = 99;520const SNAPSHOT_MAX_ENTRIES = 99;
205521
206type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;522type SnapshotStorage = Pick<Storage, "getItem" | "setItem">;
207523
208const snapshotStorageKey = (environmentId: string) => `t3.pullRequests.list:${environmentId}`;524/**
525 * Keyed by the whole set of environments the list was read from: connecting or dropping one
526 * changes which rows belong on the page, and a snapshot taken from a different set would
527 * hydrate rows no longer being read.
528 */
529export const pullRequestEnvironmentSetKey = (environmentIds: ReadonlyArray<string>): string =>
530 [...environmentIds].sort((left, right) => left.localeCompare(right)).join(",");
531
532const snapshotStorageKey = (environmentSetKey: string) =>
533 `t3.pullRequests.list:${environmentSetKey}`;
209534
210/**535/**
211 * The priority groups' own server-filtered answers, carried with the feed. An authored pull536 * The priority groups' own server-filtered answers, carried with the feed. An authored pull
...@@ -213,13 +538,13 @@ const snapshotStorageKey = (environmentId: string) => `t3.pullRequests.list:${en...@@ -213,13 +538,13 @@ const snapshotStorageKey = (environmentId: string) => `t3.pullRequests.list:${en
213 * cold-starts into an Authored group missing exactly the rows that made it worth having.538 * cold-starts into an Authored group missing exactly the rows that made it worth having.
214 */539 */
215export interface PullRequestPartitionsSnapshot {540export interface PullRequestPartitionsSnapshot {
216 readonly authored: ReadonlyArray<PullRequestListEntry>;541 readonly authored: ReadonlyArray<EnvironmentPullRequestEntry>;
217 readonly reviewing: ReadonlyArray<PullRequestListEntry>;542 readonly reviewing: ReadonlyArray<EnvironmentPullRequestEntry>;
218}543}
219544
220export interface PullRequestListSnapshot {545export interface PullRequestListSnapshot {
221 readonly scope: string;546 readonly scope: string;
222 readonly data: PullRequestListResult;547 readonly data: MergedPullRequestList;
223 readonly partitions?: PullRequestPartitionsSnapshot | undefined;548 readonly partitions?: PullRequestPartitionsSnapshot | undefined;
224}549}
225550
...@@ -229,15 +554,26 @@ export interface PullRequestListSnapshot {...@@ -229,15 +554,26 @@ export interface PullRequestListSnapshot {
229 * otherwise crash the list on every reload until the key is cleared. A snapshot from before a554 * otherwise crash the list on every reload until the key is cleared. A snapshot from before a
230 * schema change is rejected the same way, which is exactly the cold start it would have broken.555 * schema change is rejected the same way, which is exactly the cold start it would have broken.
231 */556 */
557const EnvironmentPullRequestEntrySchema = Schema.Struct({
558 ...PullRequestListEntry.fields,
559 environmentId: EnvironmentId,
560});
561
232const decodeSnapshot = Schema.decodeUnknownOption(562const decodeSnapshot = Schema.decodeUnknownOption(
233 Schema.Struct({563 Schema.Struct({
234 scope: Schema.String,564 scope: Schema.String,
235 data: PullRequestListResult,565 data: Schema.Struct({
566 ...PullRequestListResult.fields,
567 entries: Schema.Array(EnvironmentPullRequestEntrySchema),
568 // Per environment here, unlike the wire shape, which is per repository within one.
569 nextCursors: Schema.Record(Schema.String, PullRequestListResult.fields.nextCursors),
570 truncatedEnvironments: Schema.Array(Schema.String),
571 }),
236 // Optional so a snapshot written before the partitions existed still hydrates the feed.572 // Optional so a snapshot written before the partitions existed still hydrates the feed.
237 partitions: Schema.optional(573 partitions: Schema.optional(
238 Schema.Struct({574 Schema.Struct({
239 authored: Schema.Array(PullRequestListEntry),575 authored: Schema.Array(EnvironmentPullRequestEntrySchema),
240 reviewing: Schema.Array(PullRequestListEntry),576 reviewing: Schema.Array(EnvironmentPullRequestEntrySchema),
241 }),577 }),
242 ),578 ),
243 }),579 }),
...@@ -252,10 +588,10 @@ const decodeSnapshot = Schema.decodeUnknownOption(...@@ -252,10 +588,10 @@ const decodeSnapshot = Schema.decodeUnknownOption(
252 */588 */
253export function readPullRequestListSnapshot(589export function readPullRequestListSnapshot(
254 storage: SnapshotStorage | undefined,590 storage: SnapshotStorage | undefined,
255 environmentId: string,591 environmentSetKey: string,
256): PullRequestListSnapshot | null {592): PullRequestListSnapshot | null {
257 try {593 try {
258 const raw = storage?.getItem(snapshotStorageKey(environmentId));594 const raw = storage?.getItem(snapshotStorageKey(environmentSetKey));
259 if (!raw) return null;595 if (!raw) return null;
260 const decoded = decodeSnapshot(JSON.parse(raw));596 const decoded = decodeSnapshot(JSON.parse(raw));
261 return decoded._tag === "Some" ? decoded.value : null;597 return decoded._tag === "Some" ? decoded.value : null;
...@@ -266,12 +602,12 @@ export function readPullRequestListSnapshot(...@@ -266,12 +602,12 @@ export function readPullRequestListSnapshot(
266602
267export function writePullRequestListSnapshot(603export function writePullRequestListSnapshot(
268 storage: SnapshotStorage | undefined,604 storage: SnapshotStorage | undefined,
269 environmentId: string,605 environmentSetKey: string,
270 snapshot: PullRequestListSnapshot,606 snapshot: PullRequestListSnapshot,
271): void {607): void {
272 try {608 try {
273 storage?.setItem(609 storage?.setItem(
274 snapshotStorageKey(environmentId),610 snapshotStorageKey(environmentSetKey),
275 JSON.stringify({611 JSON.stringify({
276 scope: snapshot.scope,612 scope: snapshot.scope,
277 data: {613 data: {
...@@ -281,6 +617,8 @@ export function writePullRequestListSnapshot(...@@ -281,6 +617,8 @@ export function writePullRequestListSnapshot(
281 // position in a listing the host has long since forgotten.617 // position in a listing the host has long since forgotten.
282 errors: [],618 errors: [],
283 nextCursors: {},619 nextCursors: {},
620 // Where a listing stopped is as stale as the cursor that named it.
621 truncatedEnvironments: [],
284 },622 },
285 ...(snapshot.partitions === undefined623 ...(snapshot.partitions === undefined
286 ? {}624 ? {}
...@@ -316,6 +654,73 @@ export function resolveProjectScope<Id extends string>(...@@ -316,6 +654,73 @@ export function resolveProjectScope<Id extends string>(
316 return projects.some((project) => project.id === projectId) ? projectId : undefined;654 return projects.some((project) => project.id === projectId) ? projectId : undefined;
317}655}
318656
657/**
658 * The project an id names, on the server that owns it. A project id is only unique within its own
659 * environment, so an id alone can name two projects on two connected machines: with a server in
660 * hand the answer is exact, and without one it is only given where a single environment has that
661 * id — narrowing to the wrong machine reads an empty list nobody asked for.
662 */
663export function findScopedProject<
664 Project extends { readonly id: string; readonly environmentId: string },
665>(
666 projects: ReadonlyArray<Project>,
667 environmentId: string | null | undefined,
668 projectId: string | undefined,
669): Project | undefined {
670 if (projectId === undefined) return undefined;
671 const matches = projects.filter((project) => project.id === projectId);
672 if (environmentId === null || environmentId === undefined) {
673 return matches.length === 1 ? matches[0] : undefined;
674 }
675 return matches.find((project) => project.environmentId === environmentId);
676}
677
678/**
679 * Which environments a listing should ask, once the project scope is known. Scoping to a project
680 * scopes to the server that owns it, saving every other server a read that could only answer with
681 * nothing. Where an id is ambiguous — two servers holding the same project id, with no server
682 * named — only the servers that actually hold that id are asked: a server without it may still
683 * hold an unrelated project of its own under the same string, and asking it would return that
684 * project's rows rather than an honest empty answer.
685 */
686export function resolveQueryEnvironmentIds<Id extends string>(
687 environmentIds: ReadonlyArray<Id>,
688 projects: ReadonlyArray<{ readonly id: string; readonly environmentId: Id }>,
689 scopedProject: { readonly environmentId: Id } | undefined,
690 scopedProjectId: string | undefined,
691 projectsKnown: boolean,
692): ReadonlyArray<Id> {
693 if (scopedProject !== undefined) {
694 return environmentIds.filter((environmentId) => environmentId === scopedProject.environmentId);
695 }
696 // Before the servers have said what they hold, an id nothing matches is an id nothing has been
697 // asked about yet — reading none of them would show an empty page for a project that is there.
698 if (scopedProjectId === undefined || !projectsKnown) return environmentIds;
699 const holders = new Set(
700 projects
701 .filter((project) => project.id === scopedProjectId)
702 .map((project) => project.environmentId),
703 );
704 return environmentIds.filter((environmentId) => holders.has(environmentId));
705}
706
707/**
708 * The server a saved selection names, before its project is looked up against it. Still
709 * connecting is not the same as gone: a server the workspace already knows about is kept named
710 * here even while it has nothing to answer with yet, so the lookup that follows finds nothing
711 * under it rather than falling through to a different server's project of the same id. Only a
712 * name outside the workspace's whole catalog falls back to the page's own scope, which is what
713 * lets a link to a server since removed still resolve by project id alone.
714 */
715export function resolveSelectedEnvironmentId<Id extends string>(
716 namedEnvironmentId: Id | undefined,
717 knownEnvironmentIds: ReadonlySet<Id>,
718 fallbackEnvironmentId: Id | null,
719): Id | null {
720 if (namedEnvironmentId === undefined) return fallbackEnvironmentId;
721 return knownEnvironmentIds.has(namedEnvironmentId) ? namedEnvironmentId : fallbackEnvironmentId;
722}
723
319/**724/**
320 * How well a row answers the text that was searched for, as a number to order by.725 * How well a row answers the text that was searched for, as a number to order by.
321 *726 *
...@@ -353,10 +758,10 @@ export function scorePullRequestMatch(entry: PullRequestListEntry, query: string...@@ -353,10 +758,10 @@ export function scorePullRequestMatch(entry: PullRequestListEntry, query: string
353 * Search results in the order they answer the question, most convincing first, and by recency758 * Search results in the order they answer the question, most convincing first, and by recency
354 * among equals. Only for a search: without one, a listing is a timeline and recency is the order.759 * among equals. Only for a search: without one, a listing is a timeline and recency is the order.
355 */760 */
356export function rankPullRequestMatches(761export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
357 entries: ReadonlyArray<PullRequestListEntry>,762 entries: ReadonlyArray<Entry>,
358 query: string,763 query: string,
359): ReadonlyArray<PullRequestListEntry> {764): ReadonlyArray<Entry> {
360 if (query.trim().length === 0) return entries;765 if (query.trim().length === 0) return entries;
361 return entries.toSorted((left, right) => {766 return entries.toSorted((left, right) => {
362 const byScore = scorePullRequestMatch(right, query) - scorePullRequestMatch(left, query);767 const byScore = scorePullRequestMatch(right, query) - scorePullRequestMatch(left, query);
...@@ -369,11 +774,13 @@ export function rankPullRequestMatches(...@@ -369,11 +774,13 @@ export function rankPullRequestMatches(
369 * listing that carried them is not second-guessed — and only where they have arrived, since a row774 * listing that carried them is not second-guessed — and only where they have arrived, since a row
370 * draws perfectly well without them in the meantime.775 * draws perfectly well without them in the meantime.
371 */776 */
372export function withDiffStat(777export function withDiffStat<
373 entry: PullRequestListEntry,778 Entry extends PullRequestListEntry & { readonly environmentId: string },
779>(
780 entry: Entry,
374 statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,781 statsByRow: ReadonlyMap<string, { readonly additions: number; readonly deletions: number }>,
375): PullRequestListEntry {782): Entry {
376 if (entry.additions !== 0 || entry.deletions !== 0) return entry;783 if (entry.additions !== 0 || entry.deletions !== 0) return entry;
377 const stat = statsByRow.get(`${entry.projectId} ${entry.number}`);784 const stat = statsByRow.get(diffStatKey(entry));
378 return stat === undefined ? entry : { ...entry, ...stat };785 return stat === undefined ? entry : { ...entry, ...stat };
379}786}
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+47
...@@ -2,12 +2,14 @@ import type {...@@ -2,12 +2,14 @@ import type {
2 PullRequestActor,2 PullRequestActor,
3 PullRequestCheck,3 PullRequestCheck,
4 PullRequestCheckStatus,4 PullRequestCheckStatus,
5 PullRequestChecksState,
5 PullRequestMergeability,6 PullRequestMergeability,
6 PullRequestState,7 PullRequestState,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import {9import {
9 CircleCheckIcon,10 CircleCheckIcon,
10 CircleDashedIcon,11 CircleDashedIcon,
12 CircleDotIcon,
11 CircleXIcon,13 CircleXIcon,
12 GitMergeIcon,14 GitMergeIcon,
13 GitPullRequestClosedIcon,15 GitPullRequestClosedIcon,
...@@ -144,6 +146,51 @@ export function PullRequestCheckStatusIcon({ status }: { status: PullRequestChec...@@ -144,6 +146,51 @@ export function PullRequestCheckStatusIcon({ status }: { status: PullRequestChec
144 );146 );
145}147}
146148
149/**
150 * The rollup a listing row carries, which is one word rather than the checks behind it. The
151 * headline is GitHub's own wording, so a reader who knows that page reads this one the same way.
152 */
153const CHECKS_STATE_PRESENTATION = {
154 passing: {
155 label: "All checks have passed",
156 Icon: CircleCheckIcon,
157 toneClassName: "text-emerald-600 dark:text-emerald-300/90",
158 },
159 failing: {
160 label: "Some checks were not successful",
161 Icon: CircleXIcon,
162 toneClassName: "text-destructive",
163 },
164 pending: {
165 label: "Some checks haven't completed yet",
166 Icon: CircleDotIcon,
167 toneClassName: "text-amber-600 dark:text-amber-400/90",
168 },
169} as const satisfies Record<
170 PullRequestChecksState,
171 { label: string; Icon: typeof CircleCheckIcon; toneClassName: string }
172>;
173
174export function pullRequestChecksStatePresentation(state: PullRequestChecksState) {
175 return CHECKS_STATE_PRESENTATION[state];
176}
177
178/**
179 * The same rollup the server sends with a listing row, worked out here from the checks a detail
180 * already holds — so the header shows the icon without a second field travelling with it.
181 *
182 * Null for a change request with no checks: nothing to show beats a tick nobody earned.
183 */
184export function pullRequestChecksState(
185 checks: ReadonlyArray<PullRequestCheck>,
186): PullRequestChecksState | null {
187 if (checks.length === 0) return null;
188 const statuses = checks.map((check) => check.status);
189 if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing";
190 if (statuses.includes("pending")) return "pending";
191 return statuses.includes("success") ? "passing" : null;
192}
193
147export function PullRequestActorAvatar({194export function PullRequestActorAvatar({
148 actor,195 actor,
149 className,196 className,
apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts created+259
...@@ -0,0 +1,259 @@
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 assignProjectsToEnvironments,
6 resolvePickableEnvironments,
7 type AssignableProject,
8} from "./pullRequestProjectAssignment.logic";
9
10const project = (
11 id: string,
12 environmentId: string,
13 canonicalKey?: string,
14): AssignableProject & { workspaceRoot: string } => ({
15 id: id as ProjectId,
16 environmentId: environmentId as EnvironmentId,
17 repositoryIdentity: canonicalKey === undefined ? null : { canonicalKey },
18 workspaceRoot: `/srv/${environmentId}/${id}`,
19});
20
21const envs = (...ids: ReadonlyArray<string>) => ids as ReadonlyArray<EnvironmentId>;
22
23const plain = (assignment: Map<EnvironmentId, ProjectId[]>) =>
24 Object.fromEntries([...assignment].map(([id, projectIds]) => [id, projectIds]));
25
26describe("one server per repository", () => {
27 it("lets the first server list a repository both hold", () => {
28 const assignment = assignProjectsToEnvironments(
29 [
30 project("a1", "env-1", "github.com/acme/app"),
31 project("b1", "env-1", "github.com/acme/tools"),
32 project("a2", "env-2", "github.com/acme/app"),
33 project("c2", "env-2", "github.com/acme/site"),
34 ],
35 envs("env-1", "env-2"),
36 "env-1" as EnvironmentId,
37 );
38 expect(plain(assignment)).toEqual({ "env-1": ["a1", "b1"], "env-2": ["c2"] });
39 });
40
41 it("prefers the named server over the first one", () => {
42 const assignment = assignProjectsToEnvironments(
43 [
44 project("a1", "env-1", "github.com/acme/app"),
45 project("a2", "env-2", "github.com/acme/app"),
46 ],
47 envs("env-1", "env-2"),
48 "env-2" as EnvironmentId,
49 );
50 expect(plain(assignment)).toEqual({ "env-2": ["a2"] });
51 });
52
53 it("drops a server with nothing of its own", () => {
54 const assignment = assignProjectsToEnvironments(
55 [
56 project("a1", "env-1", "github.com/acme/app"),
57 project("a2", "env-2", "github.com/acme/app"),
58 ],
59 envs("env-1", "env-2"),
60 "env-1" as EnvironmentId,
61 );
62 expect(assignment.has("env-2" as EnvironmentId)).toBe(false);
63 });
64
65 it("keeps every copy of a project that has no identity to compare", () => {
66 const assignment = assignProjectsToEnvironments(
67 [project("p1", "env-1"), project("p2", "env-2")],
68 envs("env-1", "env-2"),
69 "env-1" as EnvironmentId,
70 );
71 expect(plain(assignment)).toEqual({ "env-1": ["p1"], "env-2": ["p2"] });
72 });
73
74 it("keeps a repository listed by the one server that holds it", () => {
75 const assignment = assignProjectsToEnvironments(
76 [
77 project("a1", "env-1", "github.com/acme/app"),
78 project("b2", "env-2", "gitlab.com/acme/app"),
79 ],
80 envs("env-1", "env-2"),
81 "env-1" as EnvironmentId,
82 );
83 expect(plain(assignment)).toEqual({ "env-1": ["a1"], "env-2": ["b2"] });
84 });
85
86 it("keeps a server's own worktrees of the repository it lists", () => {
87 const assignment = assignProjectsToEnvironments(
88 [
89 project("a1", "env-1", "github.com/acme/app"),
90 project("a1-wt", "env-1", "GitHub.com/acme/app"),
91 project("a2", "env-2", "github.com/acme/app"),
92 ],
93 envs("env-1", "env-2"),
94 "env-1" as EnvironmentId,
95 );
96 expect(plain(assignment)).toEqual({ "env-1": ["a1", "a1-wt"] });
97 });
98
99 it("reads two servers' copies of one repository as one however the remote is cased", () => {
100 const assignment = assignProjectsToEnvironments(
101 [
102 project("a1", "env-1", "git.example.com/Team/App"),
103 project("a2", "env-2", "GIT.example.com/team/app"),
104 ],
105 envs("env-1", "env-2"),
106 "env-1" as EnvironmentId,
107 );
108 expect(plain(assignment)).toEqual({ "env-1": ["a1"] });
109 });
110
111 it("ignores a project on a server that is not being read", () => {
112 const assignment = assignProjectsToEnvironments(
113 [
114 project("a1", "env-1", "github.com/acme/app"),
115 project("a2", "env-2", "github.com/acme/app"),
116 ],
117 envs("env-2"),
118 "env-2" as EnvironmentId,
119 );
120 expect(plain(assignment)).toEqual({ "env-2": ["a2"] });
121 });
122
123 it("answers the same whatever order the projects arrive in", () => {
124 const projects = [
125 project("a2", "env-2", "github.com/acme/app"),
126 project("a1", "env-1", "github.com/acme/app"),
127 ];
128 const forward = assignProjectsToEnvironments(projects, envs("env-1", "env-2"), null);
129 const backward = assignProjectsToEnvironments(
130 projects.toReversed(),
131 envs("env-1", "env-2"),
132 null,
133 );
134 expect(plain(forward)).toEqual({ "env-1": ["a1"] });
135 expect(plain(backward)).toEqual(plain(forward));
136 });
137});
138
139const connected = (...ids: ReadonlyArray<string>) =>
140 ids.map((id) => ({ environmentId: id as EnvironmentId, label: `Server ${id}` }));
141
142const on = (environmentId: string, projectId: string) => ({
143 environmentId: environmentId as EnvironmentId,
144 projectId: projectId as ProjectId,
145});
146
147describe("where a pull request can be acted on", () => {
148 it("offers every server holding the repository, the panel's own first", () => {
149 const pickable = resolvePickableEnvironments(
150 on("env-2", "a2"),
151 [
152 project("a1", "env-1", "github.com/acme/app"),
153 project("a2", "env-2", "github.com/acme/app"),
154 project("c3", "env-3", "github.com/acme/site"),
155 ],
156 connected("env-1", "env-2", "env-3"),
157 );
158 expect(pickable).toEqual([
159 {
160 environmentId: "env-2",
161 projectId: "a2",
162 workspaceRoot: "/srv/env-2/a2",
163 label: "Server env-2",
164 },
165 {
166 environmentId: "env-1",
167 projectId: "a1",
168 workspaceRoot: "/srv/env-1/a1",
169 label: "Server env-1",
170 },
171 ]);
172 });
173
174 it("matches copies however the remote is cased", () => {
175 const pickable = resolvePickableEnvironments(
176 on("env-1", "a1"),
177 [
178 project("a1", "env-1", "github.com/acme/app"),
179 project("a2", "env-2", "GitHub.com/ACME/app"),
180 ],
181 connected("env-1", "env-2"),
182 );
183 expect(pickable.map((entry) => entry.environmentId)).toEqual(["env-1", "env-2"]);
184 });
185
186 it("offers nothing where one server holds the repository", () => {
187 expect(
188 resolvePickableEnvironments(
189 on("env-1", "a1"),
190 [
191 project("a1", "env-1", "github.com/acme/app"),
192 project("c2", "env-2", "github.com/acme/site"),
193 ],
194 connected("env-1", "env-2"),
195 ),
196 ).toEqual([]);
197 });
198
199 it("offers nothing for a project with no identity to compare", () => {
200 expect(
201 resolvePickableEnvironments(
202 on("env-1", "p1"),
203 [project("p1", "env-1"), project("p2", "env-2")],
204 connected("env-1", "env-2"),
205 ),
206 ).toEqual([]);
207 });
208
209 it("offers nothing while the projects are still arriving", () => {
210 expect(resolvePickableEnvironments(on("env-1", "a1"), [], connected("env-1", "env-2"))).toEqual(
211 [],
212 );
213 });
214
215 it("leaves out a server that is not connected", () => {
216 expect(
217 resolvePickableEnvironments(
218 on("env-1", "a1"),
219 [
220 project("a1", "env-1", "github.com/acme/app"),
221 project("a2", "env-2", "github.com/acme/app"),
222 ],
223 connected("env-1"),
224 ),
225 ).toEqual([]);
226 });
227
228 it("names one copy per server, so two worktrees are one choice", () => {
229 const pickable = resolvePickableEnvironments(
230 on("env-1", "a1"),
231 [
232 project("a1", "env-1", "github.com/acme/app"),
233 project("a1-wt", "env-1", "github.com/acme/app"),
234 project("a2", "env-2", "github.com/acme/app"),
235 project("a2-wt", "env-2", "github.com/acme/app"),
236 ],
237 connected("env-1", "env-2"),
238 );
239 expect(pickable.map((entry) => entry.projectId)).toEqual(["a1", "a2"]);
240 });
241
242 it("keeps the panel's own worktree rather than the server's first copy", () => {
243 const pickable = resolvePickableEnvironments(
244 on("env-1", "a1-wt"),
245 [
246 project("a1", "env-1", "github.com/acme/app"),
247 project("a1-wt", "env-1", "github.com/acme/app"),
248 project("a2", "env-2", "github.com/acme/app"),
249 ],
250 connected("env-1", "env-2"),
251 );
252 expect(pickable[0]).toEqual({
253 environmentId: "env-1",
254 projectId: "a1-wt",
255 workspaceRoot: "/srv/env-1/a1-wt",
256 label: "Server env-1",
257 });
258 });
259});
apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts created+130
...@@ -0,0 +1,130 @@
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2
3/** The little of a project this needs: who holds it, and which repository it is a copy of. */
4export interface AssignableProject {
5 readonly id: ProjectId;
6 readonly environmentId: EnvironmentId;
7 readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined;
8}
9
10/**
11 * The remote's normalized URL is what says "same repository" across machines — it comes from the
12 * remote, not from a local path. Empty where the project has no identity to compare with.
13 *
14 * `normalizeGitRemoteUrl` already lower-cases the whole remote, so the key arrives cased one way
15 * whatever the remote said; the fold here only guards a key assembled some other way.
16 */
17function repositoryKey(project: AssignableProject): string | undefined {
18 return project.repositoryIdentity?.canonicalKey?.toLowerCase();
19}
20
21/**
22 * Two servers can hold the same repository, and both would list the same pull requests, so one
23 * copy is picked by its repository key.
24 *
25 * A project with no identity is never de-duplicated: nothing proves it is a copy of anything, and
26 * dropping it would lose its rows outright.
27 */
28export function assignProjectsToEnvironments(
29 projects: ReadonlyArray<AssignableProject>,
30 environmentIds: ReadonlyArray<EnvironmentId>,
31 preferredEnvironmentId?: EnvironmentId | null,
32): Map<EnvironmentId, ProjectId[]> {
33 const rank = new Map(environmentIds.map((id, index) => [id, index] as const));
34 // Which server lists each repository: the preferred one where it has it, else the first.
35 const owner = new Map<string, EnvironmentId>();
36 for (const project of projects) {
37 const key = repositoryKey(project);
38 if (!key) continue;
39 const environmentRank = rank.get(project.environmentId);
40 if (environmentRank === undefined) continue;
41 const current = owner.get(key);
42 if (current === undefined) {
43 owner.set(key, project.environmentId);
44 continue;
45 }
46 if (current === preferredEnvironmentId) continue;
47 if (
48 project.environmentId === preferredEnvironmentId ||
49 environmentRank < (rank.get(current) ?? Number.MAX_SAFE_INTEGER)
50 ) {
51 owner.set(key, project.environmentId);
52 }
53 }
54 const assignment = new Map<EnvironmentId, ProjectId[]>();
55 for (const project of projects) {
56 if (!rank.has(project.environmentId)) continue;
57 const key = repositoryKey(project);
58 if (key && owner.get(key) !== project.environmentId) continue;
59 const listed = assignment.get(project.environmentId);
60 if (listed === undefined) assignment.set(project.environmentId, [project.id]);
61 else listed.push(project.id);
62 }
63 return assignment;
64}
65
66/** A copy of the repository the reader could act on, named by the server holding it. */
67export interface PickableEnvironment {
68 readonly environmentId: EnvironmentId;
69 readonly projectId: ProjectId;
70 readonly workspaceRoot: string;
71 readonly label: string;
72}
73
74/**
75 * Which servers a pull request could be acted on, given the one it is listed under.
76 *
77 * Listing picks a single server per repository so each pull request appears once; acting is a
78 * different question. Checking the branch out, or handing it to a thread, happens wherever the
79 * reader means to work — and the copy the listing happened not to pick is just as real.
80 *
81 * Empty where there is no choice to offer: one server, no repository identity to match copies by,
82 * or projects not read yet. The caller renders nothing then, so a lone server keeps the surface it
83 * has always had.
84 */
85export function resolvePickableEnvironments(
86 current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
87 projects: ReadonlyArray<AssignableProject & { readonly workspaceRoot: string }>,
88 environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>,
89): ReadonlyArray<PickableEnvironment> {
90 const own = projects.find(
91 (project) =>
92 project.environmentId === current.environmentId && project.id === current.projectId,
93 );
94 const key = own === undefined ? undefined : repositoryKey(own);
95 const ownLabel = environments.find(
96 (environment) => environment.environmentId === current.environmentId,
97 )?.label;
98 if (own === undefined || !key || ownLabel === undefined) return [];
99 const others = environments.flatMap((environment) => {
100 if (environment.environmentId === current.environmentId) return [];
101 // One entry per server, whichever copy comes first: a server holding two worktrees of the
102 // repository is still one place to act, and what is being picked here is the server.
103 const copy = projects.find(
104 (project) =>
105 project.environmentId === environment.environmentId && repositoryKey(project) === key,
106 );
107 return copy === undefined
108 ? []
109 : [
110 {
111 environmentId: environment.environmentId,
112 projectId: copy.id,
113 workspaceRoot: copy.workspaceRoot,
114 label: environment.label,
115 },
116 ];
117 });
118 if (others.length === 0) return [];
119 // The panel's own server first: it is what everything else on the panel is showing, so it is
120 // also what acting means until the reader says otherwise.
121 return [
122 {
123 environmentId: current.environmentId,
124 projectId: own.id,
125 workspaceRoot: own.workspaceRoot,
126 label: ownLabel,
127 },
128 ...others,
129 ];
130}
apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts created+210
...@@ -0,0 +1,210 @@
1import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 applyPendingPullRequestReactions,
6 PULL_REQUEST_REACTION_ORDER,
7 pullRequestReactionEmoji,
8 pullRequestReactionName,
9 pullRequestReactionTooltip,
10} from "./pullRequestReactions.logic";
11
12function reaction(overrides: Partial<PullRequestReaction> = {}): PullRequestReaction {
13 return {
14 content: "heart",
15 count: 1,
16 actors: ["octocat"],
17 viewerHasReacted: false,
18 ...overrides,
19 };
20}
21
22describe("reaction presentation", () => {
23 it("names and draws all eight, in GitHub's picker order", () => {
24 expect(PULL_REQUEST_REACTION_ORDER).toEqual([
25 "thumbs-up",
26 "thumbs-down",
27 "laugh",
28 "hooray",
29 "confused",
30 "heart",
31 "rocket",
32 "eyes",
33 ]);
34 expect(PULL_REQUEST_REACTION_ORDER.map(pullRequestReactionEmoji)).toEqual([
35 "👍",
36 "👎",
37 "😄",
38 "🎉",
39 "😕",
40 "❤️",
41 "🚀",
42 "👀",
43 ]);
44 expect(pullRequestReactionName("thumbs-up")).toBe("thumbs up");
45 expect(pullRequestReactionName("eyes")).toBe("eyes");
46 });
47});
48
49describe("reaction tooltip", () => {
50 it("reads as GitHub's sentence for one, two and three names", () => {
51 expect(
52 pullRequestReactionTooltip(reaction({ content: "thumbs-up", count: 1, actors: ["Bil0000"] })),
53 ).toBe("Bil0000 reacted with thumbs up emoji");
54 expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["Bil0000", "octocat"] }))).toBe(
55 "Bil0000 and octocat reacted with heart emoji",
56 );
57 expect(
58 pullRequestReactionTooltip(
59 reaction({
60 content: "eyes",
61 count: 3,
62 actors: ["Bil0000", "octocat"],
63 viewerHasReacted: true,
64 }),
65 ),
66 ).toBe("You, Bil0000, and octocat reacted with eyes emoji");
67 });
68
69 it("counts everyone past the third name, including the ones the host never named", () => {
70 expect(
71 pullRequestReactionTooltip(
72 reaction({
73 content: "rocket",
74 count: 15,
75 actors: ["a", "b", "c", "d"],
76 viewerHasReacted: true,
77 }),
78 ),
79 ).toBe("You, a, b, and 12 others reacted with rocket emoji");
80 // A host that counted more than it named still says who is missing.
81 expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["octocat"] }))).toBe(
82 "octocat and 1 other reacted with heart emoji",
83 );
84 // Nothing named at all leaves the count to speak for itself, and nobody to be "other" than.
85 expect(pullRequestReactionTooltip(reaction({ count: 4, actors: [] }))).toBe(
86 "4 people reacted with heart emoji",
87 );
88 expect(pullRequestReactionTooltip(reaction({ count: 1, actors: [] }))).toBe(
89 "1 person reacted with heart emoji",
90 );
91 });
92
93 it("names the viewer as You, ahead of the other people who reacted", () => {
94 // The host already leaves the viewer's own login out of `actors`.
95 expect(
96 pullRequestReactionTooltip(
97 reaction({ count: 3, actors: ["Bil0000", "octocat"], viewerHasReacted: true }),
98 ),
99 ).toBe("You, Bil0000, and octocat reacted with heart emoji");
100 });
101
102 it("names nobody as You when the viewer has not reacted", () => {
103 expect(
104 pullRequestReactionTooltip(
105 reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: false }),
106 ),
107 ).toBe("Bil0000 and octocat reacted with heart emoji");
108 });
109
110 it("names actors as given, and leaves off You, for a host with no room for the viewer", () => {
111 // `count` says two and `actors` already lists two logins, one of them the viewer's own —
112 // there is no slot left for "You" that wouldn't invent or hide a real reactor.
113 expect(
114 pullRequestReactionTooltip(
115 reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: true }),
116 ),
117 ).toBe("Bil0000 and octocat reacted with heart emoji");
118 // Same shape past the naming cap: the display limit still leaves an honest remainder.
119 expect(
120 pullRequestReactionTooltip(
121 reaction({
122 count: 4,
123 actors: ["Bil0000", "octocat", "hubot", "zzz"],
124 viewerHasReacted: true,
125 }),
126 ),
127 ).toBe("Bil0000, octocat, hubot, and 1 other reacted with heart emoji");
128 });
129
130 it("keeps naming the viewer You when the host does leave them room", () => {
131 // `actors` has fewer logins than `count`, so the viewer fits without being counted twice.
132 expect(
133 pullRequestReactionTooltip(
134 reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true }),
135 ),
136 ).toBe("You and octocat reacted with heart emoji");
137 });
138});
139
140describe("reaction tooltip, with a reaction in flight", () => {
141 it("still says You after an optimistic react, even at full capacity", () => {
142 // The host's last snapshot had every reactor named and nobody left over; the optimistic
143 // bump grows `count` without touching `actors`, so there is now room for "You" and the
144 // sentence should use it rather than reading as a non-compliant host.
145 const applied = applyPendingPullRequestReactions(
146 [reaction({ count: 2, actors: ["a", "b"], viewerHasReacted: false })],
147 new Map([["heart", true] as const]),
148 );
149 expect(pullRequestReactionTooltip(applied[0]!)).toBe("You, a, and b reacted with heart emoji");
150 });
151
152 it("drops You after an optimistic un-react, without treating the host as non-compliant", () => {
153 const applied = applyPendingPullRequestReactions(
154 [reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true })],
155 new Map([["heart", false] as const]),
156 );
157 expect(pullRequestReactionTooltip(applied[0]!)).toBe("octocat reacted with heart emoji");
158 });
159});
160
161describe("pending reactions", () => {
162 const pending = (
163 entries: ReadonlyArray<readonly [PullRequestReactionContent, boolean]>,
164 ): ReadonlyMap<PullRequestReactionContent, boolean> => new Map(entries);
165
166 it("returns the host's list untouched while nothing is in flight", () => {
167 const reactions = [reaction()];
168 expect(applyPendingPullRequestReactions(reactions, pending([]))).toBe(reactions);
169 });
170
171 it("adds a reaction nobody had yet, in picker order", () => {
172 const applied = applyPendingPullRequestReactions(
173 [reaction({ content: "rocket", count: 2, actors: ["a", "b"] })],
174 pending([["thumbs-up", true]]),
175 );
176 expect(applied).toEqual([
177 { content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true },
178 { content: "rocket", count: 2, actors: ["a", "b"], viewerHasReacted: false },
179 ]);
180 });
181
182 it("joins and leaves an existing reaction, and drops the pill nobody is left on", () => {
183 expect(
184 applyPendingPullRequestReactions(
185 [reaction({ count: 2, actors: ["a", "b"] })],
186 pending([["heart", true]]),
187 ),
188 ).toEqual([{ content: "heart", count: 3, actors: ["a", "b"], viewerHasReacted: true }]);
189 expect(
190 applyPendingPullRequestReactions(
191 [reaction({ count: 2, actors: ["a", "b"], viewerHasReacted: true })],
192 pending([["heart", false]]),
193 ),
194 ).toEqual([{ content: "heart", count: 1, actors: ["a", "b"], viewerHasReacted: false }]);
195 expect(
196 applyPendingPullRequestReactions(
197 [reaction({ count: 1, viewerHasReacted: true })],
198 pending([["heart", false]]),
199 ),
200 ).toEqual([]);
201 });
202
203 it("ignores a pending state the host has already caught up with", () => {
204 const reactions = [reaction({ count: 3, viewerHasReacted: true })];
205 expect(applyPendingPullRequestReactions(reactions, pending([["heart", true]]))).toEqual(
206 reactions,
207 );
208 expect(applyPendingPullRequestReactions([], pending([["heart", false]]))).toEqual([]);
209 });
210});
apps/web/src/components/pullRequest/pullRequestReactions.logic.ts created+108
...@@ -0,0 +1,108 @@
1import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
2
3/** The picker's order, which is GitHub's: the two verdicts first, then the rest as it lists them. */
4export const PULL_REQUEST_REACTION_ORDER: ReadonlyArray<PullRequestReactionContent> = [
5 "thumbs-up",
6 "thumbs-down",
7 "laugh",
8 "hooray",
9 "confused",
10 "heart",
11 "rocket",
12 "eyes",
13];
14
15const REACTION_EMOJI: Record<PullRequestReactionContent, string> = {
16 "thumbs-up": "👍",
17 "thumbs-down": "👎",
18 laugh: "😄",
19 hooray: "🎉",
20 confused: "😕",
21 heart: "❤️",
22 rocket: "🚀",
23 eyes: "👀",
24};
25
26/** The spoken names GitHub uses in its own hover text, which is what a screen reader reads out. */
27const REACTION_NAME: Record<PullRequestReactionContent, string> = {
28 "thumbs-up": "thumbs up",
29 "thumbs-down": "thumbs down",
30 laugh: "laugh",
31 hooray: "hooray",
32 confused: "confused",
33 heart: "heart",
34 rocket: "rocket",
35 eyes: "eyes",
36};
37
38export function pullRequestReactionEmoji(content: PullRequestReactionContent): string {
39 return REACTION_EMOJI[content];
40}
41
42export function pullRequestReactionName(content: PullRequestReactionContent): string {
43 return REACTION_NAME[content];
44}
45
46/** Past three names the sentence stops being readable and starts being a list. */
47const NAMED_ACTOR_LIMIT = 3;
48
49function joinNames(parts: ReadonlyArray<string>): string {
50 if (parts.length <= 1) return parts[0] ?? "";
51 if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
52 return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
53}
54
55/** "others" only alongside somebody named; on its own a count is people, not other people. */
56function countRemainder(count: number, named: boolean): string {
57 if (named) return `${count} ${count === 1 ? "other" : "others"}`;
58 return `${count} ${count === 1 ? "person" : "people"}`;
59}
60
61/**
62 * Who reacted, in GitHub's sentence. The viewer reads as "You" and comes first, because that is
63 * the name they are looking for; a compliant host already leaves the viewer's own login out of
64 * `actors` when they have reacted, leaving room for "You" without ever exceeding `count`. This
65 * function has no viewer login to match against `actors`, so it cannot tell a non-compliant host
66 * apart by name — but such a host gives itself away by leaving no room: `actors` alone already
67 * accounts for everyone `count` claims. Then the viewer is already in `actors` under their own
68 * login, and naming them "You" too would either invent a person or hide a real one, so `actors`
69 * is named as given instead. The cap still guards further: the sentence never names more people
70 * than `count` claims, and the remainder is counted rather than named — a host reports fewer
71 * logins than it counts, so `count` is the only trustworthy total.
72 */
73export function pullRequestReactionTooltip(reaction: PullRequestReaction): string {
74 const viewerHasRoom = reaction.actors.length < reaction.count;
75 const names =
76 reaction.viewerHasReacted && viewerHasRoom ? ["You", ...reaction.actors] : [...reaction.actors];
77 const shown = names.slice(0, Math.min(NAMED_ACTOR_LIMIT, reaction.count));
78 const others = Math.max(0, reaction.count - shown.length);
79 const parts = [...shown, ...(others > 0 ? [countRemainder(others, shown.length > 0)] : [])];
80 return `${joinNames(parts)} reacted with ${pullRequestReactionName(reaction.content)} emoji`;
81}
82
83/**
84 * The list as it should be drawn while a reaction is still in flight. Reading a change request
85 * back from the host takes seconds, and a pill that does not move until then reads as a press
86 * that did nothing.
87 */
88export function applyPendingPullRequestReactions(
89 reactions: ReadonlyArray<PullRequestReaction>,
90 pending: ReadonlyMap<PullRequestReactionContent, boolean>,
91): ReadonlyArray<PullRequestReaction> {
92 if (pending.size === 0) return reactions;
93 const byContent = new Map(reactions.map((reaction) => [reaction.content, reaction] as const));
94 for (const [content, reacted] of pending) {
95 const current = byContent.get(content);
96 if (current === undefined) {
97 if (reacted) {
98 byContent.set(content, { content, count: 1, actors: [], viewerHasReacted: true });
99 }
100 continue;
101 }
102 if (current.viewerHasReacted === reacted) continue;
103 const count = current.count + (reacted ? 1 : -1);
104 if (count <= 0) byContent.delete(content);
105 else byContent.set(content, { ...current, count, viewerHasReacted: reacted });
106 }
107 return PULL_REQUEST_REACTION_ORDER.flatMap((content) => byContent.get(content) ?? []);
108}
apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.test.ts created+49
...@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";
4
5describe("section collapse scroll anchoring", () => {
6 it("returns the section's natural offset when its heading is pinned", () => {
7 expect(
8 sectionCollapseAnchorScrollTop({
9 scrollTop: 600,
10 viewportTop: 264,
11 sectionTop: -200,
12 headingTop: 264,
13 }),
14 ).toBe(136);
15 });
16
17 it("allows a fractional sticky-edge difference", () => {
18 expect(
19 sectionCollapseAnchorScrollTop({
20 scrollTop: 500,
21 viewportTop: 264,
22 sectionTop: 100,
23 headingTop: 264.5,
24 }),
25 ).toBe(336);
26 });
27
28 it("leaves the scroll position alone before the heading becomes sticky", () => {
29 expect(
30 sectionCollapseAnchorScrollTop({
31 scrollTop: 120,
32 viewportTop: 264,
33 sectionTop: 320,
34 headingTop: 320,
35 }),
36 ).toBeNull();
37 });
38
39 it("does not produce a negative scroll offset", () => {
40 expect(
41 sectionCollapseAnchorScrollTop({
42 scrollTop: 20,
43 viewportTop: 264,
44 sectionTop: 200,
45 headingTop: 264,
46 }),
47 ).toBe(0);
48 });
49});
apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.ts created+20
...@@ -0,0 +1,20 @@
1const STICKY_EDGE_TOLERANCE_PX = 1;
2
3/**
4 * Returns the scroll offset that puts a section's natural top where its sticky heading is now.
5 * Collapsing removes the body below that heading; without this adjustment, the unchanged numeric
6 * offset points into the next section and makes its sticky heading appear to replace the one the
7 * reader pressed.
8 */
9export function sectionCollapseAnchorScrollTop(input: {
10 readonly scrollTop: number;
11 readonly viewportTop: number;
12 readonly sectionTop: number;
13 readonly headingTop: number;
14}): number | null {
15 const sectionHasScrolledPastViewport = input.sectionTop < input.viewportTop;
16 const headingIsPinned = input.headingTop <= input.viewportTop + STICKY_EDGE_TOLERANCE_PX;
17 if (!sectionHasScrolledPastViewport || !headingIsPinned) return null;
18
19 return Math.max(0, input.scrollTop + input.sectionTop - input.viewportTop);
20}
apps/web/src/components/sidebar/SidebarChrome.tsx+7-4
...@@ -9,7 +9,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router";...@@ -9,7 +9,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router";
99
10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
11import { cn } from "../../lib/utils";11import { cn } from "../../lib/utils";
12import { usePrimaryEnvironment } from "../../state/environments";12import { useEnvironments } from "../../state/environments";
13import {13import {
14 resolveEnvironmentIdentificationPillLabel,14 resolveEnvironmentIdentificationPillLabel,
15 resolveSidebarStageBackdropVariant,15 resolveSidebarStageBackdropVariant,
...@@ -128,9 +128,12 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -128,9 +128,12 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
128 ? "pull-requests"128 ? "pull-requests"
129 : null,129 : null,
130 });130 });
131 const primaryEnvironment = usePrimaryEnvironment();131 const { environments } = useEnvironments();
132 const pullRequestsSupported =132 // The page reads every connected server, so one of them offering pull requests is enough for
133 primaryEnvironment?.serverConfig?.environment.capabilities.pullRequests === true;133 // the link to lead somewhere.
134 const pullRequestsSupported = environments.some(
135 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
136 );
134 const closeMobileSidebar = useCallback(() => {137 const closeMobileSidebar = useCallback(() => {
135 if (isMobile) {138 if (isMobile) {
136 setOpenMobile(false);139 setOpenMobile(false);
apps/web/src/lib/openPullRequestLink.ts+22-12
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";1import type { EnvironmentId, LocalApi, ScopedThreadRef } from "@t3tools/contracts";
2import { useNavigate } from "@tanstack/react-router";2import { useNavigate } from "@tanstack/react-router";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { type MouseEvent, useCallback } from "react";4import { type MouseEvent, useCallback } from "react";
...@@ -197,20 +197,28 @@ export function useOpenChangeRequestLink(...@@ -197,20 +197,28 @@ export function useOpenChangeRequestLink(
197 (event, targetUrl, targetThreadRef) => {197 (event, targetUrl, targetThreadRef) => {
198 if (shouldOpenPullRequestExternally(event)) return false;198 if (shouldOpenPullRequestExternally(event)) return false;
199 const resolvedThreadRef = targetThreadRef ?? threadRef;199 const resolvedThreadRef = targetThreadRef ?? threadRef;
200 const environmentId = resolvedThreadRef?.environmentId ?? primaryEnvironmentId;200 const parsed = parseChangeRequestUrl(targetUrl);
201 if (201 if (parsed === null) return false;
202 environmentId === null ||202 const reads = (environmentId: string) =>
203 serverConfigs.get(environmentId)?.environment.capabilities.pullRequests !== true203 serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests ===
204 ) {204 true;
205 return false;
206 }
207 // Beside a thread the panel reads on that thread's environment, so a project from another205 // Beside a thread the panel reads on that thread's environment, so a project from another
208 // one could not be read there whatever its remote says: two environments can hold the same206 // one could not be read there whatever its remote says: two environments can hold the same
209 // repository, and handing the panel the wrong one's id opens a surface that never loads.207 // repository, and handing the panel the wrong one's id opens a surface that never loads.
210 const projects = allProjects.filter((project) => project.environmentId === environmentId);208 //
211 const parsed = parseChangeRequestUrl(targetUrl);209 // The page has no such tie — it lists every server at once — so the link is resolved
212 const project = parsed === null ? undefined : findProjectForChangeRequest(projects, parsed);210 // against all of them, the primary first where two hold the same repository.
213 if (parsed === null || project === undefined) return false;211 const projects = resolvedThreadRef
212 ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId)
213 : allProjects
214 .filter((project) => reads(project.environmentId))
215 .toSorted(
216 (left, right) =>
217 Number(right.environmentId === primaryEnvironmentId) -
218 Number(left.environmentId === primaryEnvironmentId),
219 );
220 const project = findProjectForChangeRequest(projects, parsed);
221 if (project === undefined || !reads(project.environmentId)) return false;
214 event.preventDefault();222 event.preventDefault();
215 event.stopPropagation();223 event.stopPropagation();
216 if (resolvedThreadRef) {224 if (resolvedThreadRef) {
...@@ -233,6 +241,8 @@ export function useOpenChangeRequestLink(...@@ -233,6 +241,8 @@ export function useOpenChangeRequestLink(
233 repository: parsed.repository,241 repository: parsed.repository,
234 number: parsed.number,242 number: parsed.number,
235 selectedProjectId: project.id,243 selectedProjectId: project.id,
244 // Named so the page opens the right one of two servers holding this project.
245 selectedEnvironmentId: project.environmentId,
236 },246 },
237 });247 });
238 return true;248 return true;
apps/web/src/rightPanelStore.test.ts+122
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 selectActiveRightPanelSurface,9 selectActiveRightPanelSurface,
10 selectSelectedRightPanelSurface,10 selectSelectedRightPanelSurface,
11 selectThreadRightPanelState,11 selectThreadRightPanelState,
12 updatePullRequestTabStatus,
12 useRightPanelStore,13 useRightPanelStore,
13} from "./rightPanelStore";14} from "./rightPanelStore";
1415
...@@ -419,6 +420,127 @@ describe("rightPanelStore", () => {...@@ -419,6 +420,127 @@ describe("rightPanelStore", () => {
419 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));420 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));
420 });421 });
421422
423 it("keeps one pull request read from two servers as two tabs", () => {
424 const local = {
425 environmentId: "local",
426 projectId: "project-a",
427 repository: "pingdotgg/t3code",
428 number: 4909,
429 };
430 const remote = { ...local, environmentId: "remote" };
431
432 useRightPanelStore.getState().openPullRequest(refA, local);
433 useRightPanelStore.getState().openPullRequest(refA, remote);
434
435 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
436 expect(state.surfaces.map((surface) => surface.id)).toEqual([
437 pullRequestSurfaceId(local),
438 pullRequestSurfaceId(remote),
439 ]);
440 });
441
442 it("keeps the page's panel tabs reachable when the set of connected servers changes", () => {
443 // The pull-requests page keys its one shared panel by a fixed sentinel environment, not by
444 // whichever capable server happens to sort first (see PULL_REQUESTS_PANEL_ENVIRONMENT_ID in
445 // _chat.pull-requests.tsx) — a server disconnecting must not move every open tab to a store
446 // key nobody wrote them under.
447 const panelId = ThreadId.make("pull-requests-panel");
448 const stableRef = scopeThreadRef("pull-requests-panel" as EnvironmentId, panelId);
449 const fromServerA = {
450 environmentId: "server-a",
451 projectId: "project-a",
452 repository: "pingdotgg/t3code",
453 number: 1,
454 };
455 const fromServerB = {
456 environmentId: "server-b",
457 projectId: "project-b",
458 repository: "pingdotgg/t3code",
459 number: 2,
460 };
461
462 // Both servers connected: tabs from each open under the one stable ref.
463 useRightPanelStore.getState().openPullRequest(stableRef, fromServerA);
464 useRightPanelStore.getState().openPullRequest(stableRef, fromServerB);
465
466 // Server A disconnects. The stable ref does not depend on which servers remain connected, so
467 // the same lookup still finds both tabs.
468 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, stableRef);
469 expect(state.surfaces.map((surface) => surface.id)).toEqual([
470 pullRequestSurfaceId(fromServerA),
471 pullRequestSurfaceId(fromServerB),
472 ]);
473
474 // The bug this guards against: a ref keyed by the first capable environment instead of a
475 // fixed sentinel changes identity when that environment drops out, and a lookup under the new
476 // key finds nothing even though the tabs are still sitting under the old one.
477 const refWhileBothConnected = scopeThreadRef("server-a" as EnvironmentId, panelId);
478 const refAfterServerADisconnects = scopeThreadRef("server-b" as EnvironmentId, panelId);
479 expect(refWhileBothConnected).not.toEqual(refAfterServerADisconnects);
480 expect(
481 selectThreadRightPanelState(
482 useRightPanelStore.getState().byThreadKey,
483 refAfterServerADisconnects,
484 ).surfaces,
485 ).toEqual([]);
486 });
487
488 describe("updatePullRequestTabStatus", () => {
489 const status = (isDraft: boolean) => ({
490 projectId: "project-a",
491 repository: "pingdotgg/t3code",
492 number: 4909,
493 state: "open" as const,
494 isDraft,
495 });
496
497 // Regression for the tab wearing no state: this failed when the status was written under a
498 // key rebuilt from the pull request while the tab strip reads it under the surface's own id.
499 it("keys a status under the same id a surface opened from an environment carries", () => {
500 const target = {
501 environmentId: "remote",
502 projectId: "project-a",
503 repository: "pingdotgg/t3code",
504 number: 4909,
505 };
506 useRightPanelStore.getState().openPullRequest(refA, target);
507 const surface = selectSelectedRightPanelSurface(
508 useRightPanelStore.getState().byThreadKey,
509 refA,
510 );
511 expect(surface).not.toBeNull();
512
513 const statuses = updatePullRequestTabStatus({}, surface!.id, status(false));
514 expect(statuses[surface!.id]).toEqual(status(false));
515 });
516
517 it("keys a status under the same id a thread surface with no environment carries", () => {
518 const target = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 };
519 useRightPanelStore.getState().openPullRequest(refA, target);
520 const surface = selectSelectedRightPanelSurface(
521 useRightPanelStore.getState().byThreadKey,
522 refA,
523 );
524 expect(surface).not.toBeNull();
525
526 const statuses = updatePullRequestTabStatus({}, surface!.id, status(false));
527 expect(statuses[surface!.id]).toEqual(status(false));
528 });
529
530 it("returns the identical map when the tab's state and draft flag are unchanged", () => {
531 const first = updatePullRequestTabStatus({}, "pull-request:1", status(false));
532 const second = updatePullRequestTabStatus(first, "pull-request:1", status(false));
533 expect(second).toBe(first);
534 });
535
536 it("replaces the entry when the draft flag changes", () => {
537 const first = updatePullRequestTabStatus({}, "pull-request:1", status(false));
538 const second = updatePullRequestTabStatus(first, "pull-request:1", status(true));
539 expect(second).not.toBe(first);
540 expect(second["pull-request:1"]).toEqual(status(true));
541 });
542 });
543
422 it("tracks one surface per terminal session", () => {544 it("tracks one surface per terminal session", () => {
423 useRightPanelStore.getState().openTerminal(refA, "term-1");545 useRightPanelStore.getState().openTerminal(refA, "term-1");
424 useRightPanelStore.getState().openTerminal(refA, "term-2");546 useRightPanelStore.getState().openTerminal(refA, "term-2");
apps/web/src/rightPanelStore.ts+40-3
...@@ -52,6 +52,12 @@ export type RightPanelSurface =...@@ -52,6 +52,12 @@ export type RightPanelSurface =
52 */52 */
53 id: `pull-request:${string}`;53 id: `pull-request:${string}`;
54 kind: "pull-request";54 kind: "pull-request";
55 /**
56 * Which server the change request was read from. The list spans every connected one, so
57 * two of them can hold the same project id; a panel beside a thread leaves this out and
58 * takes the environment from its own ref.
59 */
60 environmentId?: string;
55 projectId: string;61 projectId: string;
56 repository: string;62 repository: string;
57 number: number;63 number: number;
...@@ -86,7 +92,7 @@ interface RightPanelStoreState {...@@ -86,7 +92,7 @@ interface RightPanelStoreState {
86 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;92 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
87 openPullRequest: (93 openPullRequest: (
88 ref: ScopedThreadRef,94 ref: ScopedThreadRef,
89 target: { projectId: string; repository: string; number: number },95 target: { environmentId?: string; projectId: string; repository: string; number: number },
90 ) => void;96 ) => void;
91 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;97 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
92 splitTerminal: (98 splitTerminal: (
...@@ -161,14 +167,20 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({...@@ -161,14 +167,20 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({
161export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>;167export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>;
162168
163export function pullRequestSurfaceId(target: {169export function pullRequestSurfaceId(target: {
170 environmentId?: string;
164 projectId: string;171 projectId: string;
165 repository: string;172 repository: string;
166 number: number;173 number: number;
167}): PullRequestSurface["id"] {174}): PullRequestSurface["id"] {
168 return `pull-request:${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;175 // The environment leads the id where there is one, so the same change request read from two
176 // servers is two tabs rather than one tab that changes its mind about which server it is on.
177 const scope =
178 target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;
179 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;
169}180}
170181
171export function pullRequestSurface(target: {182export function pullRequestSurface(target: {
183 environmentId?: string;
172 projectId: string;184 projectId: string;
173 repository: string;185 repository: string;
174 number: number;186 number: number;
...@@ -176,12 +188,30 @@ export function pullRequestSurface(target: {...@@ -176,12 +188,30 @@ export function pullRequestSurface(target: {
176 return {188 return {
177 id: pullRequestSurfaceId(target),189 id: pullRequestSurfaceId(target),
178 kind: "pull-request",190 kind: "pull-request",
191 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),
179 projectId: target.projectId,192 projectId: target.projectId,
180 repository: target.repository,193 repository: target.repository,
181 number: target.number,194 number: target.number,
182 };195 };
183}196}
184197
198/**
199 * A pull-request tab's status map with one entry set. Keyed by the surface the panel is showing
200 * rather than by a key rebuilt from the status, so the tab is found again whether or not that
201 * surface was opened with an environment on it. Returns the same map when the tab's own fields
202 * have not changed, so a caller can skip a re-render.
203 */
204export function updatePullRequestTabStatus<Status extends { state: unknown; isDraft: boolean }>(
205 statuses: Readonly<Record<string, Status>>,
206 surfaceId: string,
207 status: Status,
208): Readonly<Record<string, Status>> {
209 return statuses[surfaceId]?.state === status.state &&
210 statuses[surfaceId]?.isDraft === status.isDraft
211 ? statuses
212 : { ...statuses, [surfaceId]: status };
213}
214
185const upsertSurface = (215const upsertSurface = (
186 current: ThreadRightPanelState,216 current: ThreadRightPanelState,
187 surface: RightPanelSurface,217 surface: RightPanelSurface,
...@@ -260,7 +290,14 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -260,7 +290,14 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
260 ) {290 ) {
261 return [];291 return [];
262 }292 }
263 return [pullRequestSurface(surface)];293 const { environmentId, ...rest } = surface;
294 // Anything else stored under that name is not an environment.
295 return [
296 pullRequestSurface({
297 ...rest,
298 ...(typeof environmentId === "string" ? { environmentId } : {}),
299 }),
300 ];
264 }301 }
265 if (surface.kind !== "terminal") return [surface];302 if (surface.kind !== "terminal") return [surface];
266 if (303 if (
apps/web/src/routes/_chat.pull-requests.tsx+619-215
...@@ -4,7 +4,9 @@ import type {...@@ -4,7 +4,9 @@ import type {
4 EnvironmentId,4 EnvironmentId,
5 ProjectId,5 ProjectId,
6 PullRequestInvolvement,6 PullRequestInvolvement,
7 PullRequestListEntry,7 PullRequestListCursors,
8 PullRequestListFilters,
9 PullRequestListInput,
8 PullRequestListResult,10 PullRequestListResult,
9 PullRequestListState,11 PullRequestListState,
10 SourceControlProviderKind,12 SourceControlProviderKind,
...@@ -13,6 +15,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";...@@ -13,6 +15,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
13import {15import {
14 ChevronDownIcon,16 ChevronDownIcon,
15 EyeIcon,17 EyeIcon,
18 MonitorIcon,
19 ServerIcon,
16 GitMergeIcon,20 GitMergeIcon,
17 GitPullRequestClosedIcon,21 GitPullRequestClosedIcon,
18 GitPullRequestIcon,22 GitPullRequestIcon,
...@@ -26,21 +30,31 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro...@@ -26,21 +30,31 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
2630
27import {31import {
28 filterPullRequestsByInvolvement,32 filterPullRequestsByInvolvement,
33 findScopedProject,
29 groupPullRequestsByInvolvement,34 groupPullRequestsByInvolvement,
35 matchesPullRequestFilters,
30 matchesPullRequestQuery,36 matchesPullRequestQuery,
37 parsePullRequestQuery,
31 narrowPullRequestsToFilters,38 narrowPullRequestsToFilters,
32 mergePullRequestDiffStats,39 mergePullRequestDiffStats,
33 partitionPullRequestsWithPriority,40 partitionPullRequestsWithPriority,
34 pullRequestEntryKey,41 pullRequestEntryKey,
42 pullRequestEntryViewer,
35 rankPullRequestMatches,43 rankPullRequestMatches,
44 pullRequestEnvironmentSetKey,
36 readPullRequestListSnapshot,45 readPullRequestListSnapshot,
37 resolveProjectScope,46 resolveProjectScope,
47 resolveQueryEnvironmentIds,
48 resolveSelectedEnvironmentId,
38 withDiffStat,49 withDiffStat,
39 writePullRequestListSnapshot,50 writePullRequestListSnapshot,
40 scorePullRequestMatch,51 scorePullRequestMatch,
52 type EnvironmentPullRequestEntry,
53 type MergedPullRequestList,
41 type PullRequestDiffStats,54 type PullRequestDiffStats,
42 type PullRequestPartitionsSnapshot,55 type PullRequestPartitionsSnapshot,
43} from "../components/pullRequest/pullRequestList.logic";56} from "../components/pullRequest/pullRequestList.logic";
57import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
44import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";58import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
45import {59import {
46 PullRequestFiltersMenu,60 PullRequestFiltersMenu,
...@@ -65,18 +79,22 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../...@@ -65,18 +79,22 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
65import { SidebarInset } from "../components/ui/sidebar";79import { SidebarInset } from "../components/ui/sidebar";
66import { useLiveRefresh } from "../hooks/useLiveRefresh";80import { useLiveRefresh } from "../hooks/useLiveRefresh";
67import {81import {
68 pullRequestSurfaceId,
69 selectActiveRightPanelSurface,82 selectActiveRightPanelSurface,
70 selectSelectedRightPanelSurface,83 selectSelectedRightPanelSurface,
71 selectThreadRightPanelState,84 selectThreadRightPanelState,
85 updatePullRequestTabStatus,
72 useRightPanelStore,86 useRightPanelStore,
73 type PullRequestSurface,87 type PullRequestSurface,
74} from "../rightPanelStore";88} from "../rightPanelStore";
75import { useDebouncedValue } from "../state/queries";89import { useDebouncedValue } from "../state/queries";
76import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";90import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
77import { usePrimaryEnvironment } from "../state/environments";91import { useEnvironments } from "../state/environments";
78import { pullRequestEnvironment } from "../state/pullRequests";92import {
79import { useEnvironmentQuery } from "../state/query";93 pullRequestEnvironment,
94 usePullRequestList,
95 usePullRequestListStats,
96 type EnvironmentQueryTarget,
97} from "../state/pullRequests";
80import { useAtomCommand } from "../state/use-atom-command";98import { useAtomCommand } from "../state/use-atom-command";
81import { cn } from "~/lib/utils";99import { cn } from "~/lib/utils";
82import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";100import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
...@@ -85,6 +103,11 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";...@@ -85,6 +103,11 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
85export interface PullRequestsSearch {103export interface PullRequestsSearch {
86 readonly involvement: PullRequestInvolvement;104 readonly involvement: PullRequestInvolvement;
87 readonly state: PullRequestListState;105 readonly state: PullRequestListState;
106 /**
107 * Narrows the list to one server. Absent means every connected one, which is the default the
108 * page has now — so a link written before servers could be chosen still opens the whole list.
109 */
110 readonly environmentId?: EnvironmentId;
88 /** Scopes the list. Separate from the selection so one cannot silently change the other. */111 /** Scopes the list. Separate from the selection so one cannot silently change the other. */
89 readonly projectId?: ProjectId;112 readonly projectId?: ProjectId;
90 /**113 /**
...@@ -95,7 +118,20 @@ export interface PullRequestsSearch {...@@ -95,7 +118,20 @@ export interface PullRequestsSearch {
95 readonly repository?: string;118 readonly repository?: string;
96 readonly number?: number;119 readonly number?: number;
97 readonly selectedProjectId?: ProjectId;120 readonly selectedProjectId?: ProjectId;
121 /**
122 * Which server the selected pull request was read from. A project id only names a project on
123 * its own server, so this is what tells two servers holding one project apart. Optional: a
124 * link without it still opens, resolved by project id alone where that is unambiguous.
125 */
126 readonly selectedEnvironmentId?: EnvironmentId;
98 readonly q?: string;127 readonly q?: string;
128 /**
129 * The narrowings beyond state and involvement, each absent when that group is unfiltered. Flat
130 * in the URL because a link is read and edited by hand; folded into one record for the listing.
131 */
132 readonly draft?: "only" | "hide";
133 readonly review?: NonNullable<PullRequestListFilters["review"]>;
134 readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
99}135}
100136
101// The state filters wear the same glyphs the rows do, so the two read as one vocabulary.137// The state filters wear the same glyphs the rows do, so the two read as one vocabulary.
...@@ -128,6 +164,15 @@ const MAX_PAGE_SIZE = 500;...@@ -128,6 +164,15 @@ const MAX_PAGE_SIZE = 500;
128const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};164const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};
129/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */165/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */
130const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel");166const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel");
167/**
168 * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each
169 * surface already carries the server it was read from), so its store key must not move when a
170 * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so
171 * this string can never collide with one.
172 */
173const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId;
174/** Stable so a read that is not wanted right now does not re-key on every render. */
175const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
131const EMPTY_PREVIEW_SESSIONS = {};176const EMPTY_PREVIEW_SESSIONS = {};
132const EMPTY_TERMINAL_LABELS = new Map<string, string>();177const EMPTY_TERMINAL_LABELS = new Map<string, string>();
133const EMPTY_PENDING_SURFACES = new Set<string>();178const EMPTY_PENDING_SURFACES = new Set<string>();
...@@ -147,11 +192,25 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -147,11 +192,25 @@ export const Route = createFileRoute("/_chat/pull-requests")({
147 ...(typeof raw.projectId === "string" && raw.projectId192 ...(typeof raw.projectId === "string" && raw.projectId
148 ? { projectId: raw.projectId as ProjectId }193 ? { projectId: raw.projectId as ProjectId }
149 : {}),194 : {}),
195 ...(typeof raw.environmentId === "string" && raw.environmentId
196 ? { environmentId: raw.environmentId as EnvironmentId }
197 : {}),
150 ...(typeof raw.host === "string" && raw.host ? { host: raw.host.slice(0, 200) } : {}),198 ...(typeof raw.host === "string" && raw.host ? { host: raw.host.slice(0, 200) } : {}),
151 ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId199 ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId
152 ? { selectedProjectId: raw.selectedProjectId as ProjectId }200 ? { selectedProjectId: raw.selectedProjectId as ProjectId }
153 : {}),201 : {}),
202 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId
203 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }
204 : {}),
154 ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}),205 ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}),
206 ...(raw.draft === "only" || raw.draft === "hide" ? { draft: raw.draft } : {}),
207 ...(raw.review === "approved" ||
208 raw.review === "changes-requested" ||
209 raw.review === "review-required" ||
210 raw.review === "none"
211 ? { review: raw.review }
212 : {}),
213 ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}),
155 }),214 }),
156 component: PullRequestsRouteView,215 component: PullRequestsRouteView,
157});216});
...@@ -159,44 +218,155 @@ export const Route = createFileRoute("/_chat/pull-requests")({...@@ -159,44 +218,155 @@ export const Route = createFileRoute("/_chat/pull-requests")({
159function PullRequestsRouteView() {218function PullRequestsRouteView() {
160 const search = Route.useSearch();219 const search = Route.useSearch();
161 const navigate = useNavigate({ from: Route.fullPath });220 const navigate = useNavigate({ from: Route.fullPath });
162 const primaryEnvironment = usePrimaryEnvironment();221 const { environments } = useEnvironments();
163 const environmentId = primaryEnvironment?.environmentId ?? null;222 // Every connected environment that has said it can list pull requests. Sorted, so the query
164 const capabilityKnown = primaryEnvironment !== null && primaryEnvironment.serverConfig !== null;223 // keys, the scope key and the stored snapshot all read the same whichever order the
165 const pullRequestsSupported =224 // connections happened to come up in.
166 primaryEnvironment?.serverConfig?.environment.capabilities.pullRequests === true;225 const capableEnvironments = useMemo(
167 // The primary environment may still be connecting, or may predate this feature. In either226 () =>
168 // case every query remains idle until the server has explicitly advertised these APIs.227 environments
169 const pullRequestEnvironmentId = pullRequestsSupported ? environmentId : null;228 .filter(
229 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
230 )
231 .toSorted((left, right) => left.environmentId.localeCompare(right.environmentId)),
232 [environments],
233 );
234 // The server the URL asks for, kept only while it is one the page could read: a link naming a
235 // server this workspace no longer has falls back to all of them rather than to nothing.
236 const scopedEnvironmentId =
237 capableEnvironments.find((environment) => environment.environmentId === search.environmentId)
238 ?.environmentId ?? null;
239 // Every server this workspace has ever heard of, connecting or not — wider than
240 // `capableEnvironments`, which only holds the ones ready to answer.
241 const knownEnvironmentIds = useMemo(
242 () => new Set(environments.map((environment) => environment.environmentId)),
243 [environments],
244 );
245 const environmentIds = useMemo(
246 () =>
247 capableEnvironments
248 .filter(
249 (environment) =>
250 scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId,
251 )
252 .map((environment) => environment.environmentId),
253 [capableEnvironments, scopedEnvironmentId],
254 );
255 const environmentKey = useMemo(
256 () => pullRequestEnvironmentSetKey(environmentIds),
257 [environmentIds],
258 );
259 // An environment may still be connecting, or may predate this feature. Until at least one has
260 // reported, an empty set means "not known yet" rather than "no environment can", and the page
261 // waits rather than telling a reader to upgrade a server that has not spoken.
262 const capabilityKnown = environments.some((environment) => environment.serverConfig !== null);
263 const pullRequestsSupported = environmentIds.length > 0;
170 const allProjects = useProjects();264 const allProjects = useProjects();
171 // Whether the workspace has said what it holds yet. Until it has, an empty project list is265 // Whether the workspace has said what it holds yet. Until it has, an empty project list is
172 // "not loaded" rather than "none", and telling a reader to add a project they already have is266 // "not loaded" rather than "none", and telling a reader to add a project they already have is
173 // the one wrong answer the empty state can give.267 // the one wrong answer the empty state can give.
174 const projectsKnown = useAllEnvironmentShellsBootstrapped();268 const projectsKnown = useAllEnvironmentShellsBootstrapped();
175 // The page reads one environment, so a project from another one could neither be listed269 // Only the projects the page can actually read: one on an environment that cannot list pull
176 // nor acted on: scoping here keeps the filter and the selection honest.270 // requests could neither be listed nor acted on.
177 const projects = useMemo(271 const projects = useMemo(
178 () => allProjects.filter((project) => project.environmentId === environmentId),272 () => allProjects.filter((project) => environmentIds.includes(project.environmentId)),
179 [allProjects, environmentId],273 [allProjects, environmentIds],
180 );274 );
181 const scopedProjects = useMemo(275 const environmentLabels = useMemo(
182 () =>276 () =>
183 projects277 new Map(
184 .map((project) => ({278 environments.map((environment) => [environment.environmentId, environment.label] as const),
185 id: project.id,279 ),
186 title: project.title,280 [environments],
187 workspaceRoot: project.workspaceRoot,
188 }))
189 .toSorted((left, right) => left.title.localeCompare(right.title)),
190 [projects],
191 );281 );
192 // The scope the URL asks for, once the environment has had its say about whether it exists.282 const scopedProjects = useMemo(() => {
283 // Two machines can hold the same repository, so a title the workspace carries twice is told
284 // apart by the environment it lives on rather than left as two identical rows.
285 const titleCounts = new Map<string, number>();
286 for (const project of projects) {
287 titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1);
288 }
289 return projects
290 .map((project) => ({
291 id: project.id,
292 environmentId: project.environmentId,
293 title:
294 (titleCounts.get(project.title) ?? 0) > 1
295 ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}`
296 : project.title,
297 workspaceRoot: project.workspaceRoot,
298 }))
299 .toSorted((left, right) => left.title.localeCompare(right.title));
300 }, [environmentLabels, projects]);
301 // The scope the URL asks for, once the environments have had their say about whether it exists.
193 const scopedProjectId = useMemo(302 const scopedProjectId = useMemo(
194 () => resolveProjectScope(search.projectId, projects, projectsKnown),303 () => resolveProjectScope(search.projectId, projects, projectsKnown),
195 [projects, projectsKnown, search.projectId],304 [projects, projectsKnown, search.projectId],
196 );305 );
306 const scopedProject = useMemo(
307 () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId),
308 [projects, scopedEnvironmentId, scopedProjectId],
309 );
310
311 // A link from a thread or the sidebar only knows the repository, so the owning project is
312 // resolved here; an explicit `projectId` in the URL still wins.
313 const projectIdForRepository = useMemo(() => {
314 const repository = search.repository?.toLowerCase();
315 if (repository === undefined) return undefined;
316 const identity = projects.find(
317 (project) =>
318 project.repositoryIdentity?.owner &&
319 project.repositoryIdentity.name &&
320 `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() ===
321 repository &&
322 // The same `owner/name` can exist on two hosts. Without this the first match wins, and
323 // a link that named its host opens the pull request from the other one.
324 (search.host === undefined ||
325 pullRequestHostOf(
326 project.repositoryIdentity,
327 project.repositoryIdentity.provider as SourceControlProviderKind,
328 ) === search.host.toLowerCase()),
329 );
330 return identity?.id;
331 }, [projects, search.host, search.repository]);
332
333 // The selection is resolved the same way the scope is: an id no connected environment has can
334 // never be read here, and one that arrived before the projects did is not yet wrong.
335 const linkedProjectId = useMemo(
336 () => resolveProjectScope(search.selectedProjectId, projects, projectsKnown),
337 [projects, projectsKnown, search.selectedProjectId],
338 );
339 // The scope filter stands in as a last resort: a link can carry `projectId` with a repository
340 // whose identity the inference above cannot match, and refusing to open it because of the
341 // weaker signal would ignore the stronger one the URL spelled out.
342 const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId;
343 // Which server the selection belongs to. Named by the URL where a link had one to give;
344 // otherwise the project id decides, and only where exactly one server has that project.
345 // A named server still connecting is kept named rather than falling back: falling back while
346 // it is merely not ready yet would resolve the project against whatever other server has
347 // answered, which can be the wrong one where two servers share a project id. Only a name this
348 // workspace has never heard of falls back to the scope above, the same way that scope treats a
349 // stale name — a saved link naming a server that has since gone would otherwise open nothing,
350 // even where the project id it also carries names exactly one remaining server.
351 const selectedEnvironmentId = resolveSelectedEnvironmentId(
352 search.selectedEnvironmentId,
353 knownEnvironmentIds,
354 scopedEnvironmentId,
355 );
356 const selectedProject = useMemo(
357 () => findScopedProject(projects, selectedEnvironmentId, selectedProjectId),
358 [projects, selectedEnvironmentId, selectedProjectId],
359 );
360 // One panel for the page rather than one per server: the surfaces carry the server they were
361 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref
362 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab
363 // strip survives a capable server disconnecting or losing the pull-requests capability.
197 const rightPanelRef = useMemo(364 const rightPanelRef = useMemo(
198 () => (environmentId === null ? null : scopeThreadRef(environmentId, PULL_REQUESTS_PANEL_ID)),365 () =>
199 [environmentId],366 capableEnvironments.length === 0
367 ? null
368 : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID),
369 [capableEnvironments.length],
200 );370 );
201 const rightPanelState = useRightPanelStore((state) =>371 const rightPanelState = useRightPanelStore((state) =>
202 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),372 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),
...@@ -207,17 +377,27 @@ function PullRequestsRouteView() {...@@ -207,17 +377,27 @@ function PullRequestsRouteView() {
207 const selectedPullRequestSurface =377 const selectedPullRequestSurface =
208 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;378 selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null;
209 const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null;379 const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null;
380 // The open tab names its own server; a link that arrived before any tab was opened names it
381 // through the project it selected.
382 const panelEnvironmentId =
383 (activePullRequestSurface?.environmentId as EnvironmentId | undefined) ??
384 selectedProject?.environmentId ??
385 null;
210 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<386 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
211 Record<string, PullRequestTabStatus>387 Record<string, PullRequestTabStatus>
212 >({});388 >({});
213 const handlePullRequestTabStatusChange = useCallback((status: PullRequestTabStatus) => {389 // Keyed by the surface the panel is showing rather than by a key rebuilt from the status: a
214 const id = pullRequestSurfaceId(status);390 // surface opened from this page carries the environment its row was listed under, and a key
215 setPullRequestTabStatuses((current) =>391 // assembled from the pull request alone would never name that surface back.
216 current[id]?.state === status.state && current[id]?.isDraft === status.isDraft392 const activePullRequestSurfaceId = activePullRequestSurface?.id;
217 ? current393 const handlePullRequestTabStatusChange = useCallback(
218 : { ...current, [id]: status },394 (status: PullRequestTabStatus) => {
219 );395 const id = activePullRequestSurfaceId;
220 }, []);396 if (id === undefined) return;
397 setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status));
398 },
399 [activePullRequestSurfaceId],
400 );
221401
222 const updateSearch = useCallback(402 const updateSearch = useCallback(
223 (patch: {403 (patch: {
...@@ -234,9 +414,16 @@ function PullRequestsRouteView() {...@@ -234,9 +414,16 @@ function PullRequestsRouteView() {
234 ...(next.repository ? { repository: next.repository } : {}),414 ...(next.repository ? { repository: next.repository } : {}),
235 ...(next.number ? { number: next.number } : {}),415 ...(next.number ? { number: next.number } : {}),
236 ...(next.projectId ? { projectId: next.projectId } : {}),416 ...(next.projectId ? { projectId: next.projectId } : {}),
417 ...(next.environmentId ? { environmentId: next.environmentId } : {}),
237 ...(next.host ? { host: next.host } : {}),418 ...(next.host ? { host: next.host } : {}),
238 ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}),419 ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}),
420 ...(next.selectedEnvironmentId
421 ? { selectedEnvironmentId: next.selectedEnvironmentId }
422 : {}),
239 ...(next.q ? { q: next.q } : {}),423 ...(next.q ? { q: next.q } : {}),
424 ...(next.draft ? { draft: next.draft } : {}),
425 ...(next.review ? { review: next.review } : {}),
426 ...(next.checks ? { checks: next.checks } : {}),
240 };427 };
241 },428 },
242 replace: true,429 replace: true,
...@@ -250,6 +437,7 @@ function PullRequestsRouteView() {...@@ -250,6 +437,7 @@ function PullRequestsRouteView() {
250 repository: undefined,437 repository: undefined,
251 number: undefined,438 number: undefined,
252 selectedProjectId: undefined,439 selectedProjectId: undefined,
440 selectedEnvironmentId: undefined,
253 };441 };
254 const updateListScope = (patch: {442 const updateListScope = (patch: {
255 [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined;443 [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined;
...@@ -267,47 +455,170 @@ function PullRequestsRouteView() {...@@ -267,47 +455,170 @@ function PullRequestsRouteView() {
267 const typedQuery = (search.q ?? "").trim();455 const typedQuery = (search.q ?? "").trim();
268 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);456 const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS);
269 const querySettled = typedQuery === sentQuery;457 const querySettled = typedQuery === sentQuery;
458 // What was typed, split into the qualifiers the hosts can act on and the words that are left.
459 // The URL keeps the line as it was written; only the request sees the two halves.
460 const typedParsed = useMemo(() => parsePullRequestQuery(typedQuery), [typedQuery]);
461 const sentParsed = useMemo(() => parsePullRequestQuery(sentQuery), [sentQuery]);
270462
463 // One record for the hosts, rebuilt only when the URL's own fields change so the listing
464 // inputs stay identical between renders.
465 const menuFilters = useMemo(
466 (): PullRequestListFilters => ({
467 ...(search.draft ? { draft: search.draft } : {}),
468 ...(search.review ? { review: search.review } : {}),
469 ...(search.checks ? { checks: search.checks } : {}),
470 }),
471 [search.checks, search.draft, search.review],
472 );
473 const menuFiltered = Object.keys(menuFilters).length > 0;
474 // A typed qualifier wins over the menu's own answer for the same thing, since it is the more
475 // recent word on it; labels only ever come from the query, so there is nothing to overrule.
476 const filters = useMemo(
477 (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }),
478 [menuFilters, sentParsed.filters],
479 );
480 const hasFilters = Object.keys(filters).length > 0;
481 /** The same narrowings the moment they are typed, for the rows already on screen. */
482 const localFilters = useMemo(
483 (): PullRequestListFilters => ({ ...menuFilters, ...typedParsed.filters }),
484 [menuFilters, typedParsed.filters],
485 );
486 const hasLocalFilters = Object.keys(localFilters).length > 0;
487 // Scoping to a project scopes to the server that owns it, saving every other server a read
488 // that could only answer with nothing. Where an id is ambiguous — two servers holding the
489 // same project id, with no server named — only the servers that actually hold that id are
490 // asked: a server without it may still hold an unrelated project of its own under the same
491 // string, and asking it would return that project's rows rather than an honest empty answer.
492 const queryEnvironmentIds = useMemo(
493 () =>
494 resolveQueryEnvironmentIds(
495 environmentIds,
496 projects,
497 scopedProject,
498 scopedProjectId,
499 projectsKnown,
500 ),
501 [environmentIds, projects, projectsKnown, scopedProject, scopedProjectId],
502 );
503 /**
504 * Which projects each server is asked about. Two servers holding the same repository would both
505 * list the same pull requests, so each repository is listed by one of them — the first, which is
506 * where the page's actions land — and the others are asked only for what is theirs alone. A
507 * server left with nothing of its own is not read at all.
508 *
509 * Left alone while the projects are still arriving, and while the scope is a single project:
510 * that path deliberately asks both servers holding an ambiguous id.
511 */
512 const environmentQueries = useMemo((): ReadonlyArray<{
513 readonly environmentId: EnvironmentId;
514 readonly projectIds?: ReadonlyArray<ProjectId>;
515 }> => {
516 const plain = queryEnvironmentIds.map((environmentId) => ({ environmentId }));
517 if (!projectsKnown || scopedProjectId !== undefined) return plain;
518 const assignment = assignProjectsToEnvironments(
519 projects,
520 queryEnvironmentIds,
521 queryEnvironmentIds[0],
522 );
523 const totals = new Map<EnvironmentId, number>();
524 for (const project of projects) {
525 totals.set(project.environmentId, (totals.get(project.environmentId) ?? 0) + 1);
526 }
527 return queryEnvironmentIds.flatMap((environmentId) => {
528 const projectIds = assignment.get(environmentId);
529 if (projectIds === undefined) return [];
530 // It lists everything it holds anyway, so the filter is left off and a one-server workspace
531 // asks exactly the question it asked before.
532 if (projectIds.length === (totals.get(environmentId) ?? 0)) return [{ environmentId }];
533 return [{ environmentId, projectIds }];
534 });
535 }, [projects, projectsKnown, queryEnvironmentIds, scopedProjectId]);
536 // Part of the scope, since a different split is a different question and its answers must not
537 // be filed under the same page state.
538 const assignmentKey = useMemo(
539 () =>
540 environmentQueries
541 .map(({ environmentId, projectIds }) => `${environmentId}#${projectIds?.join("+") ?? "*"}`)
542 .join("|"),
543 [environmentQueries],
544 );
271 // Page size is view state, not a URL concern: a shared link should open the first page.545 // Page size is view state, not a URL concern: a shared link should open the first page.
272 const scopeKey = `${environmentId ?? ""}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}`;546 const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`;
273 const filterKey = `${scopeKey}:${sentQuery}`;547 const filterKey = `${scopeKey}:${sentQuery}`;
274 // Where the next slice carries on from, per repository, as the server handed it back. Sending548 // Where the next slice carries on from, per repository within each environment, as that
275 // it is what makes a second page cost a second page rather than the whole list again — and a549 // environment handed it back. Sending it is what makes a second page cost a second page rather
276 // repository it does not name has run out and is not read a second time.550 // than the whole list again — and a repository it does not name has run out and is not read a
551 // second time. An environment absent from it has nothing more to give at all.
277 const [page, setPage] = useState<{552 const [page, setPage] = useState<{
278 key: string;553 key: string;
279 size: number;554 size: number;
280 cursors: Record<string, string> | null;555 cursors: Readonly<Record<string, PullRequestListCursors>> | null;
281 }>({ key: filterKey, size: PAGE_SIZE, cursors: null });556 /**
557 * The environments read again from the top at the larger page rather than continued. An
558 * environment can say it has more without saying where to carry on from — its provider has
559 * no cursor to give — and a continuation naming only the others would strand its rows on
560 * the host forever.
561 */
562 regrown: ReadonlyArray<string>;
563 }>({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] });
282 const pageSize = page.key === filterKey ? page.size : PAGE_SIZE;564 const pageSize = page.key === filterKey ? page.size : PAGE_SIZE;
283 const sentCursors = page.key === filterKey ? page.cursors : null;565 const sentCursors = page.key === filterKey ? page.cursors : null;
566 const sentRegrown = page.key === filterKey ? page.regrown : [];
284567
285 // Typing a search, or clearing one, starts the list again at its first page. Without this the568 // Typing a search, or clearing one, starts the list again at its first page. Without this the
286 // paging state from before the search is still filed under these filters and comes back with569 // paging state from before the search is still filed under these filters and comes back with
287 // it, so clearing would return to the slice that had been scrolled to rather than to the list.570 // it, so clearing would return to the slice that had been scrolled to rather than to the list.
288 useEffect(() => {571 useEffect(() => {
289 setPage({ key: filterKey, size: PAGE_SIZE, cursors: null });572 setPage({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] });
290 }, [filterKey]);573 }, [filterKey]);
291574
292 const listQuery = useEnvironmentQuery(575 /** The listing input each environment is asked for, which differs only in its continuation. */
293 pullRequestEnvironmentId === null576 const listTargets = useMemo(
294 ? null577 () =>
295 : pullRequestEnvironment.list({578 environmentQueries.flatMap(({ environmentId, projectIds }) => {
296 environmentId: pullRequestEnvironmentId,579 const cursors = sentCursors?.[environmentId];
297 input: {580 // A continuation asks the environments that said where to carry on from, plus the ones
298 state: search.state,581 // that have more to give but no cursor to give it from — those are read again at the
299 // The hosts narrow by involvement themselves — GitHub by author and review request,582 // larger page. The rest have run out, and re-reading them would answer with the page
300 // and so on — so asking them is the difference between a page of results and a page583 // that is already on screen.
301 // of everything with the answer somewhere further down it.584 if (sentCursors !== null && cursors === undefined && !sentRegrown.includes(environmentId)) {
302 involvement: search.involvement,585 return [];
303 limit: pageSize,586 }
304 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),587 return [
305 ...(search.host ? { host: search.host } : {}),588 {
306 ...(sentQuery ? { query: sentQuery } : {}),589 environmentId,
307 ...(sentCursors ? { cursors: sentCursors } : {}),590 input: {
591 state: search.state,
592 // The hosts narrow by involvement themselves — GitHub by author and review
593 // request, and so on — so asking them is the difference between a page of results
594 // and a page of everything with the answer somewhere further down it.
595 involvement: search.involvement,
596 limit: pageSize,
597 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
598 ...(projectIds ? { projectIds } : {}),
599 ...(search.host ? { host: search.host } : {}),
600 ...(hasFilters ? { filters } : {}),
601 ...(sentParsed.text ? { query: sentParsed.text } : {}),
602 ...(cursors === undefined ? {} : { cursors }),
603 } satisfies PullRequestListInput,
308 },604 },
309 }),605 ];
606 }),
607 [
608 filters,
609 hasFilters,
610 pageSize,
611 environmentQueries,
612 scopedProjectId,
613 search.host,
614 search.involvement,
615 search.state,
616 sentCursors,
617 sentRegrown,
618 sentParsed.text,
619 ],
310 );620 );
621 const listQuery = usePullRequestList(listTargets);
311622
312 /**623 /**
313 * The same filters with nothing typed, read whether or not anything is. It is the same atom the624 * The same filters with nothing typed, read whether or not anything is. It is the same atom the
...@@ -319,20 +630,31 @@ function PullRequestsRouteView() {...@@ -319,20 +630,31 @@ function PullRequestsRouteView() {
319 * say which question it belongs to: mid-switch the text has already changed and the data has630 * say which question it belongs to: mid-switch the text has already changed and the data has
320 * not, and a search's answer would file itself under the workspace.631 * not, and a search's answer would file itself under the workspace.
321 */632 */
322 const baselineQuery = useEnvironmentQuery(633 const baselineTargets = useMemo(
323 pullRequestEnvironmentId === null634 () =>
324 ? null635 environmentQueries.map(({ environmentId, projectIds }) => ({
325 : pullRequestEnvironment.list({636 environmentId,
326 environmentId: pullRequestEnvironmentId,637 input: {
327 input: {638 state: search.state,
328 state: search.state,639 involvement: search.involvement,
329 involvement: search.involvement,640 limit: PAGE_SIZE,
330 limit: PAGE_SIZE,641 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
331 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),642 ...(projectIds ? { projectIds } : {}),
332 ...(search.host ? { host: search.host } : {}),643 ...(search.host ? { host: search.host } : {}),
333 },644 ...(menuFiltered ? { filters: menuFilters } : {}),
334 }),645 } satisfies PullRequestListInput,
646 })),
647 [
648 menuFiltered,
649 menuFilters,
650 environmentQueries,
651 scopedProjectId,
652 search.host,
653 search.involvement,
654 search.state,
655 ],
335 );656 );
657 const baselineQuery = usePullRequestList(baselineTargets);
336 // The priority groups' own reads. The feed below is paginated by recency, so an older authored658 // The priority groups' own reads. The feed below is paginated by recency, so an older authored
337 // or review-requested row can be missing from its first page; partitioned from these659 // or review-requested row can be missing from its first page; partitioned from these
338 // server-filtered reads instead, the priority view is complete up front and a continuation can660 // server-filtered reads instead, the priority view is complete up front and a continuation can
...@@ -340,34 +662,35 @@ function PullRequestsRouteView() {...@@ -340,34 +662,35 @@ function PullRequestsRouteView() {
340 // so no partitions are read for one. These are the same atoms the Authored and Reviewing tabs662 // so no partitions are read for one. These are the same atoms the Authored and Reviewing tabs
341 // ask for, so switching to either is answered from cache.663 // ask for, so switching to either is answered from cache.
342 const partitionsWanted = search.involvement === "all" && typedQuery.length === 0;664 const partitionsWanted = search.involvement === "all" && typedQuery.length === 0;
343 const authoredQuery = useEnvironmentQuery(665 // Built together so the two reads share one memo, and in the same field order the feed's own
344 pullRequestEnvironmentId === null || !partitionsWanted666 // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer.
345 ? null667 const partitionTargets = useMemo(() => {
346 : pullRequestEnvironment.list({668 if (!partitionsWanted) return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS };
347 environmentId: pullRequestEnvironmentId,669 const targetsFor = (involvement: PullRequestInvolvement) =>
348 input: {670 environmentQueries.map(({ environmentId, projectIds }) => ({
349 state: search.state,671 environmentId,
350 involvement: "authored",672 input: {
351 limit: PAGE_SIZE,673 state: search.state,
352 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),674 involvement,
353 ...(search.host ? { host: search.host } : {}),675 limit: PAGE_SIZE,
354 },676 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),
355 }),677 ...(projectIds ? { projectIds } : {}),
356 );678 ...(search.host ? { host: search.host } : {}),
357 const reviewingQuery = useEnvironmentQuery(679 ...(menuFiltered ? { filters: menuFilters } : {}),
358 pullRequestEnvironmentId === null || !partitionsWanted680 } satisfies PullRequestListInput,
359 ? null681 }));
360 : pullRequestEnvironment.list({682 return { authored: targetsFor("authored"), reviewing: targetsFor("reviewing") };
361 environmentId: pullRequestEnvironmentId,683 }, [
362 input: {684 menuFiltered,
363 state: search.state,685 menuFilters,
364 involvement: "reviewing",686 partitionsWanted,
365 limit: PAGE_SIZE,687 environmentQueries,
366 ...(scopedProjectId ? { projectId: scopedProjectId } : {}),688 scopedProjectId,
367 ...(search.host ? { host: search.host } : {}),689 search.host,
368 },690 search.state,
369 }),691 ]);
370 );692 const authoredQuery = usePullRequestList(partitionTargets.authored);
693 const reviewingQuery = usePullRequestList(partitionTargets.reviewing);
371 // The header's refresh punches through the server's cache before re-reading; the error and694 // The header's refresh punches through the server's cache before re-reading; the error and
372 // empty states retry plainly, because a failure is never cached.695 // empty states retry plainly, because a failure is never cached.
373 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });696 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
...@@ -382,9 +705,11 @@ function PullRequestsRouteView() {...@@ -382,9 +705,11 @@ function PullRequestsRouteView() {
382 const refreshFromHost = async () => {705 const refreshFromHost = async () => {
383 setInvalidating(true);706 setInvalidating(true);
384 try {707 try {
385 if (pullRequestEnvironmentId !== null) {708 // Every environment the page is reading, since what the reader pressed refresh for is the
386 await invalidate({ environmentId: pullRequestEnvironmentId, input: {} });709 // list in front of them rather than whichever machine happens to be first.
387 }710 await Promise.all(
711 queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })),
712 );
388 } finally {713 } finally {
389 setInvalidating(false);714 setInvalidating(false);
390 }715 }
...@@ -402,38 +727,47 @@ function PullRequestsRouteView() {...@@ -402,38 +727,47 @@ function PullRequestsRouteView() {
402 // out: a longer page reads as growth, and a search shows the rows it already has, narrowed727 // out: a longer page reads as growth, and a search shows the rows it already has, narrowed
403 // here, until the hosts answer for themselves.728 // here, until the hosts answer for themselves.
404 const [loaded, setLoaded] = useState<{729 const [loaded, setLoaded] = useState<{
405 environmentId: EnvironmentId | null;730 environmentKey: string;
406 scope: string;731 scope: string;
407 query: string;732 query: string;
408 data: PullRequestListResult;733 data: MergedPullRequestList;
409 /** The priority groups' own answers, carried so a cold start has whole groups too. */734 /** The priority groups' own answers, carried so a cold start has whole groups too. */
410 partitions?: PullRequestPartitionsSnapshot;735 partitions?: PullRequestPartitionsSnapshot;
411 } | null>(null);736 } | null>(null);
737 // A longer page is the same list with more on the end, so the rows already read stay where
738 // they were read: each answer is merged onto the last rather than replacing it, and anything
739 // new lands at the bottom. Only a different question — other filters, another search — starts
740 // the order again. Declared here, ahead of the snapshot write below, so that write can read
741 // this round's accumulation rather than only its own slice.
742 const [ordered, setOrdered] = useState<{
743 key: string;
744 entries: ReadonlyArray<EnvironmentPullRequestEntry>;
745 } | null>(null);
412 // A reload recreates the registry the queries live in, so with nothing held the page would746 // A reload recreates the registry the queries live in, so with nothing held the page would
413 // cold-start into skeletons even though almost every row is unchanged. The last answer for747 // cold-start into skeletons even though almost every row is unchanged. The last answer for
414 // this environment is kept across reloads and hydrated here as the carried rows: they render748 // this set of environments is kept across reloads and hydrated here as the carried rows: they
415 // at once — narrowed to the current filters like any carried answer — and the live read749 // render at once — narrowed to the current filters like any carried answer — and the live read
416 // reconciles them in place by key rather than replacing them with ghosts.750 // reconciles them in place by key rather than replacing them with ghosts.
417 useEffect(() => {751 useEffect(() => {
418 if (environmentId === null) return;752 if (environmentKey.length === 0) return;
419 setLoaded((current) => {753 setLoaded((current) => {
420 // Another environment's rows could not even be narrowed — nothing on a row says which754 // Rows read from a different set of environments cannot even be narrowed — one of them may
421 // environment read it — so its snapshot beats holding them.755 // no longer be connected at all — so that set's own snapshot beats holding them.
422 if (current !== null && current.environmentId === environmentId) return current;756 if (current !== null && current.environmentKey === environmentKey) return current;
423 const snapshot = readPullRequestListSnapshot(757 const snapshot = readPullRequestListSnapshot(
424 typeof window === "undefined" ? undefined : window.localStorage,758 typeof window === "undefined" ? undefined : window.localStorage,
425 environmentId,759 environmentKey,
426 );760 );
427 if (snapshot === null) return null;761 if (snapshot === null) return null;
428 return {762 return {
429 environmentId,763 environmentKey,
430 scope: snapshot.scope,764 scope: snapshot.scope,
431 query: "",765 query: "",
432 data: snapshot.data,766 data: snapshot.data,
433 ...(snapshot.partitions === undefined ? {} : { partitions: snapshot.partitions }),767 ...(snapshot.partitions === undefined ? {} : { partitions: snapshot.partitions }),
434 };768 };
435 });769 });
436 }, [environmentId]);770 }, [environmentKey]);
437 useEffect(() => {771 useEffect(() => {
438 // Only once this query has settled. While a search is being swapped in or out the text has772 // Only once this query has settled. While a search is being swapped in or out the text has
439 // already changed and the data has not, so recording them together would file the previous773 // already changed and the data has not, so recording them together would file the previous
...@@ -450,22 +784,38 @@ function PullRequestsRouteView() {...@@ -450,22 +784,38 @@ function PullRequestsRouteView() {
450 partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null784 partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null
451 ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries }785 ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries }
452 : current !== null &&786 : current !== null &&
453 current.environmentId === environmentId &&787 current.environmentKey === environmentKey &&
454 current.scope === scopeKey788 current.scope === scopeKey
455 ? current.partitions789 ? current.partitions
456 : undefined;790 : undefined;
457 // A search's answer is the search's, not the workspace's, so only unsearched lists791 // A search's answer is the search's, not the workspace's, so only unsearched lists
458 // persist. Written here where the held partitions are in reach, so a feed settling792 // persist. Written here where the held partitions are in reach, so a feed settling
459 // ahead of them cannot overwrite a stored snapshot that already had both groups.793 // ahead of them cannot overwrite a stored snapshot that already had both groups.
460 if (environmentId !== null && sentQuery.length === 0) {794 //
795 // What is written is what the reader is actually looking at, not this round's own
796 // answer: a continuation only asks the environments still paging, so its answer alone is
797 // missing both the rows read on earlier pages and the hosts of whichever environment had
798 // already run out of cursors. `ordered` carries the accumulated rows, and the baseline
799 // read — which never drops an environment for lack of a cursor — carries the full hosts.
800 if (environmentKey.length > 0 && sentQuery.length === 0) {
801 const accumulatedEntries = ordered?.key === filterKey ? ordered.entries : data.entries;
461 writePullRequestListSnapshot(802 writePullRequestListSnapshot(
462 typeof window === "undefined" ? undefined : window.localStorage,803 typeof window === "undefined" ? undefined : window.localStorage,
463 environmentId,804 environmentKey,
464 { scope: scopeKey, data, ...(partitions === undefined ? {} : { partitions }) },805 {
806 scope: scopeKey,
807 data: {
808 ...data,
809 entries: accumulatedEntries,
810 viewers: baselineQuery.data?.viewers ?? data.viewers,
811 providers: baselineQuery.data?.providers ?? data.providers,
812 },
813 ...(partitions === undefined ? {} : { partitions }),
814 },
465 );815 );
466 }816 }
467 return {817 return {
468 environmentId,818 environmentKey,
469 scope: scopeKey,819 scope: scopeKey,
470 query: sentQuery,820 query: sentQuery,
471 data,821 data,
...@@ -473,7 +823,7 @@ function PullRequestsRouteView() {...@@ -473,7 +823,7 @@ function PullRequestsRouteView() {
473 };823 };
474 });824 });
475 }, [825 }, [
476 environmentId,826 environmentKey,
477 scopeKey,827 scopeKey,
478 sentQuery,828 sentQuery,
479 listQuery.data,829 listQuery.data,
...@@ -481,15 +831,18 @@ function PullRequestsRouteView() {...@@ -481,15 +831,18 @@ function PullRequestsRouteView() {
481 partitionsWanted,831 partitionsWanted,
482 authoredQuery.data,832 authoredQuery.data,
483 reviewingQuery.data,833 reviewingQuery.data,
834 ordered,
835 filterKey,
836 baselineQuery.data,
484 ]);837 ]);
485 // Changing a filter asks a question nothing has answered yet, and the page is already holding838 // Changing a filter asks a question nothing has answered yet, and the page is already holding
486 // perfectly good rows for the last one. Rather than blank out for the round trip, those rows839 // perfectly good rows for the last one. Rather than blank out for the round trip, those rows
487 // are narrowed to the new filters and stay until the answer lands — a subset of it, never a840 // are narrowed to the new filters and stay until the answer lands — a subset of it, never a
488 // row it excludes. Narrowed to nothing there is nothing to carry, and the skeletons below are841 // row it excludes. Narrowed to nothing there is nothing to carry, and the skeletons below are
489 // right after all. Another environment's rows are dropped rather than narrowed: nothing on a842 // right after all. Rows read from a different set of environments are dropped rather than
490 // row says which environment read it.843 // narrowed: one of those environments may no longer be connected.
491 const narrowed = useMemo(() => {844 const narrowed = useMemo(() => {
492 if (loaded === null || loaded.environmentId !== environmentId || loaded.scope === scopeKey) {845 if (loaded === null || loaded.environmentKey !== environmentKey || loaded.scope === scopeKey) {
493 return null;846 return null;
494 }847 }
495 const entries = narrowPullRequestsToFilters(loaded.data.entries, {848 const entries = narrowPullRequestsToFilters(loaded.data.entries, {
...@@ -498,7 +851,7 @@ function PullRequestsRouteView() {...@@ -498,7 +851,7 @@ function PullRequestsRouteView() {
498 host: search.host,851 host: search.host,
499 });852 });
500 return entries.length === 0 ? null : { ...loaded.data, entries };853 return entries.length === 0 ? null : { ...loaded.data, entries };
501 }, [environmentId, loaded, scopeKey, scopedProjectId, search.host, search.state]);854 }, [environmentKey, loaded, scopeKey, scopedProjectId, search.host, search.state]);
502 // With nothing typed and nothing to carry on from, the answer is taken from the read that is855 // With nothing typed and nothing to carry on from, the answer is taken from the read that is
503 // keyed to exactly that question. Otherwise a search's answer lingers for a render after the856 // keyed to exactly that question. Otherwise a search's answer lingers for a render after the
504 // text has gone — the data cannot say which question it belongs to, but the read it came from857 // text has gone — the data cannot say which question it belongs to, but the read it came from
...@@ -524,19 +877,16 @@ function PullRequestsRouteView() {...@@ -524,19 +877,16 @@ function PullRequestsRouteView() {
524 /** Nothing read and nothing to carry, which is the one thing skeletons are for. */877 /** Nothing read and nothing to carry, which is the one thing skeletons are for. */
525 const firstLoad = listQuery.isPending && listData === null;878 const firstLoad = listQuery.isPending && listData === null;
526879
527 // A longer page is the same list with more on the end, so the rows already read stay where880 // `ordered` is declared above, ahead of the snapshot write, but grown here from this round's
528 // they were read: each answer is merged onto the last rather than replacing it, and anything881 // own answer.
529 // new lands at the bottom. Only a different question — other filters, another search — starts
530 // the order again.
531 const [ordered, setOrdered] = useState<{
532 key: string;
533 entries: ReadonlyArray<PullRequestListEntry>;
534 } | null>(null);
535 useEffect(() => {882 useEffect(() => {
536 if (!answered) return;883 if (!answered) return;
537 setOrdered((previous) => {884 setOrdered((previous) => {
538 if (previous === null || previous.key !== filterKey) {885 if (previous === null || previous.key !== filterKey) {
539 return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentQuery) };886 return {
887 key: filterKey,
888 entries: rankPullRequestMatches(answered.entries, sentParsed.text),
889 };
540 }890 }
541 if (sentCursors !== null) {891 if (sentCursors !== null) {
542 // A continuation is a slice, not the list: it carries only what comes after the rows892 // A continuation is a slice, not the list: it carries only what comes after the rows
...@@ -547,7 +897,7 @@ function PullRequestsRouteView() {...@@ -547,7 +897,7 @@ function PullRequestsRouteView() {
547 const arrived = answered.entries.filter((entry) => !held.has(pullRequestEntryKey(entry)));897 const arrived = answered.entries.filter((entry) => !held.has(pullRequestEntryKey(entry)));
548 const appended = rankPullRequestMatches(898 const appended = rankPullRequestMatches(
549 arrived.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)),899 arrived.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
550 sentQuery,900 sentParsed.text,
551 );901 );
552 return { key: filterKey, entries: [...previous.entries, ...appended] };902 return { key: filterKey, entries: [...previous.entries, ...appended] };
553 }903 }
...@@ -556,9 +906,9 @@ function PullRequestsRouteView() {...@@ -556,9 +906,9 @@ function PullRequestsRouteView() {
556 // since the last read at the bottom of the page — below rows a week older — where "the906 // since the last read at the bottom of the page — below rows a week older — where "the
557 // latest" is exactly what a refresh was for. The host answers in the order the page907 // latest" is exactly what a refresh was for. The host answers in the order the page
558 // reads, so its order stands; a row that moved was updated, and moving is the news.908 // reads, so its order stands; a row that moved was updated, and moving is the news.
559 return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentQuery) };909 return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentParsed.text) };
560 });910 });
561 }, [answered, filterKey, sentCursors, sentQuery]);911 }, [answered, filterKey, sentCursors, sentParsed.text]);
562912
563 // Carrying on where the last answer stopped, and only raising the page size for the hosts that913 // Carrying on where the last answer stopped, and only raising the page size for the hosts that
564 // could not say where that was.914 // could not say where that was.
...@@ -566,16 +916,27 @@ function PullRequestsRouteView() {...@@ -566,16 +916,27 @@ function PullRequestsRouteView() {
566 // continuation is a boundary in one listing, and carrying one across a search would ask the916 // continuation is a boundary in one listing, and carrying one across a search would ask the
567 // new question to start where the old one stopped — skipping its newest matches entirely.917 // new question to start where the old one stopped — skipping its newest matches entirely.
568 const nextCursors = answered?.nextCursors ?? {};918 const nextCursors = answered?.nextCursors ?? {};
919 // The environments with more rows and no cursor to reach them by. They come along with any
920 // continuation, read again at a page one step larger, which is the only way they grow.
921 const regrown = (answered?.truncatedEnvironments ?? []).filter(
922 (environmentId) => nextCursors[environmentId] === undefined,
923 );
569 const canContinue = !showingCarried && Object.keys(nextCursors).length > 0;924 const canContinue = !showingCarried && Object.keys(nextCursors).length > 0;
570 const loadMore = () => {925 const loadMore = () => {
571 if (canContinue) {926 if (canContinue) {
572 setPage({ key: filterKey, size: pageSize, cursors: nextCursors });927 setPage({
928 key: filterKey,
929 size: regrown.length === 0 ? pageSize : Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE),
930 cursors: nextCursors,
931 regrown,
932 });
573 return;933 return;
574 }934 }
575 setPage({935 setPage({
576 key: filterKey,936 key: filterKey,
577 size: Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE),937 size: Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE),
578 cursors: null,938 cursors: null,
939 regrown: [],
579 });940 });
580 };941 };
581942
...@@ -591,6 +952,7 @@ function PullRequestsRouteView() {...@@ -591,6 +952,7 @@ function PullRequestsRouteView() {
591 const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize;952 const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize;
592 setPage({953 setPage({
593 key: filterKey,954 key: filterKey,
955 regrown: [],
594 size: Math.min(956 size: Math.min(
595 Math.max(pageSize, Math.ceil(loadedCount / PAGE_SIZE) * PAGE_SIZE),957 Math.max(pageSize, Math.ceil(loadedCount / PAGE_SIZE) * PAGE_SIZE),
596 MAX_PAGE_SIZE,958 MAX_PAGE_SIZE,
...@@ -611,7 +973,7 @@ function PullRequestsRouteView() {...@@ -611,7 +973,7 @@ function PullRequestsRouteView() {
611 authoredQuery.refresh();973 authoredQuery.refresh();
612 reviewingQuery.refresh();974 reviewingQuery.refresh();
613 },975 },
614 { enabled: pullRequestEnvironmentId !== null },976 { enabled: pullRequestsSupported },
615 );977 );
616978
617 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;979 const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS;
...@@ -636,22 +998,39 @@ function PullRequestsRouteView() {...@@ -636,22 +998,39 @@ function PullRequestsRouteView() {
636 // The local pass stands in for the answer that has not arrived yet, and for the hosts that998 // The local pass stands in for the answer that has not arrived yet, and for the hosts that
637 // answered without searching at all: Azure DevOps has no text filter, so its rows arrive999 // answered without searching at all: Azure DevOps has no text filter, so its rows arrive
638 // whole and would otherwise sit under a search that never touched them.1000 // whole and would otherwise sit under a search that never touched them.
639 if (typedQuery.length === 0) return involvementEntries;1001 // The rows the hosts could not narrow themselves, for the fields a row actually carries:
1002 // a host with no filter of its own answers unnarrowed, and so does one whose answer for the
1003 // new filters has not arrived yet. Checks are absent here, because no row carries them.
1004 const narrowedEntries = hasLocalFilters
1005 ? involvementEntries.filter((entry) =>
1006 matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)),
1007 )
1008 : involvementEntries;
1009 // Newest update first once nothing is typed, so a list merged from several hosts reads in
1010 // one order rather than in each host's. With a search on, the relevance ranking above is
1011 // the order, and re-sorting by date here would undo it.
1012 if (typedParsed.text.length === 0) {
1013 return narrowedEntries.toSorted((left, right) =>
1014 right.updatedAt.localeCompare(left.updatedAt),
1015 );
1016 }
640 const answeredLocally = querySettled && !showingCarried;1017 const answeredLocally = querySettled && !showingCarried;
641 return involvementEntries.filter(1018 return narrowedEntries.filter(
642 (entry) =>1019 (entry) =>
643 (answeredLocally && searchingHosts.has(entry.host)) ||1020 (answeredLocally && searchingHosts.has(entry.host)) ||
644 matchesPullRequestQuery(entry, typedQuery),1021 matchesPullRequestQuery(entry, typedParsed.text),
645 );1022 );
646 }, [1023 }, [
647 filterKey,1024 filterKey,
1025 hasLocalFilters,
1026 localFilters,
648 listData,1027 listData,
649 ordered,1028 ordered,
650 querySettled,1029 querySettled,
651 search.involvement,1030 search.involvement,
652 searchingHosts,1031 searchingHosts,
653 showingCarried,1032 showingCarried,
654 typedQuery,1033 typedParsed.text,
655 viewers,1034 viewers,
656 ]);1035 ]);
6571036
...@@ -717,21 +1096,34 @@ function PullRequestsRouteView() {...@@ -717,21 +1096,34 @@ function PullRequestsRouteView() {
717 // authored row older than it. Once the live reads land they take over; with neither,1096 // authored row older than it. Once the live reads land they take over; with neither,
718 // the local grouping is still better than nothing.1097 // the local grouping is still better than nothing.
719 const held =1098 const held =
720 loaded !== null && loaded.environmentId === environmentId && loaded.scope === scopeKey1099 loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey
721 ? loaded.partitions1100 ? loaded.partitions
722 : undefined;1101 : undefined;
723 const authored = partitionsWanted ? (authoredQuery.data?.entries ?? held?.authored) : undefined;1102 // The priority reads answer the same question the feed does, so they take the same local
724 const reviewing = partitionsWanted1103 // narrowing: a host that cannot filter for itself would otherwise put rows into Authored
725 ? (reviewingQuery.data?.entries ?? held?.reviewing)1104 // that the filters above just took out of the feed.
726 : undefined;1105 const narrow = (rows: ReadonlyArray<EnvironmentPullRequestEntry> | undefined) =>
1106 rows === undefined || !hasLocalFilters
1107 ? rows
1108 : rows.filter((entry) =>
1109 matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)),
1110 );
1111 const authored = narrow(
1112 partitionsWanted ? (authoredQuery.data?.entries ?? held?.authored) : undefined,
1113 );
1114 const reviewing = narrow(
1115 partitionsWanted ? (reviewingQuery.data?.entries ?? held?.reviewing) : undefined,
1116 );
727 if (authored === undefined || reviewing === undefined) {1117 if (authored === undefined || reviewing === undefined) {
728 return groupPullRequestsByInvolvement(entries, viewers);1118 return groupPullRequestsByInvolvement(entries, viewers);
729 }1119 }
730 return partitionPullRequestsWithPriority(entries, authored, reviewing);1120 return partitionPullRequestsWithPriority(entries, authored, reviewing);
731 }, [1121 }, [
1122 hasLocalFilters,
1123 localFilters,
732 authoredQuery.data?.entries,1124 authoredQuery.data?.entries,
733 entries,1125 entries,
734 environmentId,1126 environmentKey,
735 loaded,1127 loaded,
736 partitionsWanted,1128 partitionsWanted,
737 reviewingQuery.data?.entries,1129 reviewingQuery.data?.entries,
...@@ -741,75 +1133,51 @@ function PullRequestsRouteView() {...@@ -741,75 +1133,51 @@ function PullRequestsRouteView() {
741 ]);1133 ]);
7421134
743 // Keyed by every row being shown — the partitions can hold rows the feed has not paged to —1135 // Keyed by every row being shown — the partitions can hold rows the feed has not paged to —
744 // so scrolling further asks only about what is new.1136 // so scrolling further asks only about what is new. One read per environment, each asking only
745 const statsInput = useMemo(1137 // about its own rows: a reference names a project, and a project belongs to one machine.
746 () => ({1138 const statsTargets = useMemo(() => {
747 refs: groups1139 const refsByEnvironment = new Map<
748 .flatMap((group) => group.entries)1140 EnvironmentId,
749 .map((entry) => ({1141 Array<{ projectId: ProjectId; repository: string; number: number }>
1142 >();
1143 for (const group of groups) {
1144 for (const entry of group.entries) {
1145 const refs = refsByEnvironment.get(entry.environmentId) ?? [];
1146 refs.push({
750 projectId: entry.projectId,1147 projectId: entry.projectId,
751 repository: entry.repository,1148 repository: entry.repository,
752 number: entry.number,1149 number: entry.number,
753 })),1150 });
754 }),1151 refsByEnvironment.set(entry.environmentId, refs);
755 [groups],1152 }
756 );1153 }
757 const statsQuery = useEnvironmentQuery(1154 return [...refsByEnvironment].map(([environmentId, refs]) => ({
758 pullRequestEnvironmentId === null || statsInput.refs.length === 01155 environmentId,
759 ? null1156 input: { refs },
760 : pullRequestEnvironment.listStats({1157 }));
761 environmentId: pullRequestEnvironmentId,1158 }, [groups]);
762 input: statsInput,1159 const statsQuery = usePullRequestListStats(statsTargets);
763 }),
764 );
765 // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts1160 // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts
766 // are merged into what is already held rather than rebuilt: every count on screen stays until1161 // are merged into what is already held rather than rebuilt: every count on screen stays until
767 // its replacement arrives.1162 // its replacement arrives.
768 const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map());1163 const [statsByRow, setStatsByRow] = useState<PullRequestDiffStats>(() => new Map());
769 useEffect(() => {1164 useEffect(() => {
770 const stats = statsQuery.data?.stats;1165 const stats = statsQuery.stats;
771 if (stats === undefined) return;1166 if (stats === null) return;
772 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));1167 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
773 }, [statsQuery.data]);1168 }, [statsQuery.stats]);
7741169
775 // A link from a thread or the sidebar only knows the repository, so the owning project is
776 // resolved here; an explicit `projectId` in the URL still wins.
777 const projectIdForRepository = useMemo(() => {
778 const repository = search.repository?.toLowerCase();
779 if (repository === undefined) return undefined;
780 const identity = projects.find(
781 (project) =>
782 project.repositoryIdentity?.owner &&
783 project.repositoryIdentity.name &&
784 `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() ===
785 repository &&
786 // The same `owner/name` can exist on two hosts. Without this the first match wins, and
787 // a link that named its host opens the pull request from the other one.
788 (search.host === undefined ||
789 pullRequestHostOf(
790 project.repositoryIdentity,
791 project.repositoryIdentity.provider as SourceControlProviderKind,
792 ) === search.host.toLowerCase()),
793 );
794 return identity?.id;
795 }, [projects, search.host, search.repository]);
796
797 // The selection is resolved the same way the scope is: an id from another environment can
798 // never be read here, and one that arrived before the projects did is not yet wrong.
799 const linkedProjectId = useMemo(
800 () => resolveProjectScope(search.selectedProjectId, projects, projectsKnown),
801 [projects, projectsKnown, search.selectedProjectId],
802 );
803 // The scope filter stands in as a last resort: a link can carry `projectId` with a repository
804 // whose identity the inference above cannot match, and refusing to open it because of the
805 // weaker signal would ignore the stronger one the URL spelled out.
806 const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId;
807 const linkedSelection = useMemo(1170 const linkedSelection = useMemo(
808 () =>1171 () =>
809 search.repository && search.number && selectedProjectId1172 search.repository && search.number && selectedProject
810 ? { repository: search.repository, number: search.number, projectId: selectedProjectId }1173 ? {
1174 environmentId: selectedProject.environmentId,
1175 repository: search.repository,
1176 number: search.number,
1177 projectId: selectedProject.id,
1178 }
811 : null,1179 : null,
812 [search.number, search.repository, selectedProjectId],1180 [search.number, search.repository, selectedProject],
813 );1181 );
814 useEffect(() => {1182 useEffect(() => {
815 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;1183 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
...@@ -819,6 +1187,7 @@ function PullRequestsRouteView() {...@@ -819,6 +1187,7 @@ function PullRequestsRouteView() {
819 const selected =1187 const selected =
820 rightPanelState.isOpen && activePullRequestSurface !== null1188 rightPanelState.isOpen && activePullRequestSurface !== null
821 ? {1189 ? {
1190 environmentId: activePullRequestSurface.environmentId,
822 repository: activePullRequestSurface.repository,1191 repository: activePullRequestSurface.repository,
823 number: activePullRequestSurface.number,1192 number: activePullRequestSurface.number,
824 projectId: activePullRequestSurface.projectId as ProjectId,1193 projectId: activePullRequestSurface.projectId as ProjectId,
...@@ -833,6 +1202,9 @@ function PullRequestsRouteView() {...@@ -833,6 +1202,9 @@ function PullRequestsRouteView() {
833 repository: surface.repository,1202 repository: surface.repository,
834 number: surface.number,1203 number: surface.number,
835 selectedProjectId: surface.projectId as ProjectId,1204 selectedProjectId: surface.projectId as ProjectId,
1205 ...(surface.environmentId === undefined
1206 ? {}
1207 : { selectedEnvironmentId: surface.environmentId as EnvironmentId }),
836 },1208 },
837 );1209 );
8381210
...@@ -884,13 +1256,15 @@ function PullRequestsRouteView() {...@@ -884,13 +1256,15 @@ function PullRequestsRouteView() {
8841256
885 // Stable so the memoized rows can skip re-rendering when the list around them changes.1257 // Stable so the memoized rows can skip re-rendering when the list around them changes.
886 const selectEntry = useCallback(1258 const selectEntry = useCallback(
887 (entry: PullRequestListEntry) => {1259 (entry: EnvironmentPullRequestEntry) => {
1260 // The surface carries the row's own server, which is what its detail reads and acts on.
888 if (rightPanelRef === null) return;1261 if (rightPanelRef === null) return;
889 useRightPanelStore.getState().openPullRequest(rightPanelRef, entry);1262 useRightPanelStore.getState().openPullRequest(rightPanelRef, entry);
890 updateSearch({1263 updateSearch({
891 repository: entry.repository,1264 repository: entry.repository,
892 number: entry.number,1265 number: entry.number,
893 selectedProjectId: entry.projectId,1266 selectedProjectId: entry.projectId,
1267 selectedEnvironmentId: entry.environmentId,
894 });1268 });
895 },1269 },
896 [rightPanelRef, updateSearch],1270 [rightPanelRef, updateSearch],
...@@ -936,7 +1310,7 @@ function PullRequestsRouteView() {...@@ -936,7 +1310,7 @@ function PullRequestsRouteView() {
936 ) : !pullRequestsSupported ? (1310 ) : !pullRequestsSupported ? (
937 <PullRequestsUnavailableState1311 <PullRequestsUnavailableState
938 title="Pull requests unavailable"1312 title="Pull requests unavailable"
939 error="Update this environment's T3 Code server to browse pull requests."1313 error="Update your T3 Code servers to browse pull requests."
940 />1314 />
941 ) : firstLoad ? (1315 ) : firstLoad ? (
942 <PullRequestListGhost rows={7} />1316 <PullRequestListGhost rows={7} />
...@@ -980,14 +1354,20 @@ function PullRequestsRouteView() {...@@ -980,14 +1354,20 @@ function PullRequestsRouteView() {
980 entry={withDiffStat(entry, statsByRow)}1354 entry={withDiffStat(entry, statsByRow)}
981 showProjectTitle1355 showProjectTitle
982 showProvider={showProvider}1356 showProvider={showProvider}
1357 {...(capableEnvironments.length > 1 &&
1358 environmentLabels.get(entry.environmentId) !== undefined
1359 ? { environmentLabel: environmentLabels.get(entry.environmentId)! }
1360 : {})}
983 // Ten is the floor the ranking gives a row whose own fields say nothing1361 // Ten is the floor the ranking gives a row whose own fields say nothing
984 // about the search: the host matched something this row cannot show.1362 // about the search: the host matched something this row cannot show.
985 matchedElsewhere={1363 matchedElsewhere={
986 typedQuery.length > 0 &&1364 typedParsed.text.length > 0 &&
987 scorePullRequestMatch(entry, typedQuery) <= MATCHED_ELSEWHERE_SCORE1365 scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE
988 }1366 }
989 selected={1367 selected={
990 selected?.repository === entry.repository && selected.number === entry.number1368 selected?.environmentId === entry.environmentId &&
1369 selected.repository === entry.repository &&
1370 selected.number === entry.number
991 }1371 }
992 onSelect={selectEntry}1372 onSelect={selectEntry}
993 />1373 />
...@@ -1037,6 +1417,16 @@ function PullRequestsRouteView() {...@@ -1037,6 +1417,16 @@ function PullRequestsRouteView() {
1037 };1417 };
1038 }),1418 }),
1039 ];1419 ];
1420 // The same shape the host pills take, so the two groups read as one control. A local
1421 // connection wears the screen it is on; every other server wears a server.
1422 const serverMenuOptions: ReadonlyArray<PullRequestFilterOption<string>> = [
1423 { value: "", label: "All servers", Icon: LayersIcon },
1424 ...capableEnvironments.map((environment) => ({
1425 value: environment.environmentId,
1426 label: environment.label,
1427 Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon,
1428 })),
1429 ];
1040 const filtersMenu = (1430 const filtersMenu = (
1041 <PullRequestFiltersMenu1431 <PullRequestFiltersMenu
1042 state={search.state}1432 state={search.state}
...@@ -1045,14 +1435,28 @@ function PullRequestsRouteView() {...@@ -1045,14 +1435,28 @@ function PullRequestsRouteView() {
1045 involvement={search.involvement}1435 involvement={search.involvement}
1046 involvementOptions={INVOLVEMENT_TABS}1436 involvementOptions={INVOLVEMENT_TABS}
1047 onInvolvement={(involvement) => updateListScope({ involvement })}1437 onInvolvement={(involvement) => updateListScope({ involvement })}
1438 filters={menuFilters}
1439 onFilters={(next) =>
1440 updateListScope({ draft: next.draft, review: next.review, checks: next.checks })
1441 }
1048 host={search.host}1442 host={search.host}
1049 hostOptions={hostMenuOptions}1443 hostOptions={hostMenuOptions}
1050 onHost={(host) => updateListScope({ host })}1444 onHost={(host) => updateListScope({ host })}
1051 environmentId={environmentId}1445 server={scopedEnvironmentId ?? undefined}
1446 serverOptions={serverMenuOptions}
1447 // Narrowing to one server drops a project scope belonging to another, which would
1448 // otherwise narrow the list to nothing with no visible filter to explain it.
1449 onServer={(server) => updateListScope({ environmentId: server, projectId: undefined })}
1052 projects={scopedProjects}1450 projects={scopedProjects}
1053 projectId={scopedProjectId}1451 projectId={scopedProjectId}
1452 projectEnvironmentId={scopedProject?.environmentId}
1054 unavailable={unavailableProjects}1453 unavailable={unavailableProjects}
1055 onProject={(projectId) => updateListScope({ projectId })}1454 // The environment comes along with the project it belongs to, so a duplicate id on
1455 // another server never gets narrowed to by mistake; picking "All projects" leaves the
1456 // server scope as it was rather than clearing it.
1457 onProject={(projectId, environmentId) =>
1458 updateListScope(environmentId === undefined ? { projectId } : { projectId, environmentId })
1459 }
1056 />1460 />
1057 );1461 );
1058 const columnProps = {1462 const columnProps = {
...@@ -1114,7 +1518,7 @@ function PullRequestsRouteView() {...@@ -1114,7 +1518,7 @@ function PullRequestsRouteView() {
1114 {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null}1518 {pullRequestsSupported && rightPanelState.isOpen ? openPanelControls : null}
1115 <PullRequestsColumn {...columnProps} />1519 <PullRequestsColumn {...columnProps} />
11161520
1117 {rightPanelState.isOpen && activePullRequestSurface && pullRequestEnvironmentId !== null ? (1521 {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? (
1118 <RightPanelTabs1522 <RightPanelTabs
1119 mode="inline"1523 mode="inline"
1120 widthStorageKey="t3code:pull-request-panel-width"1524 widthStorageKey="t3code:pull-request-panel-width"
...@@ -1158,7 +1562,7 @@ function PullRequestsRouteView() {...@@ -1158,7 +1562,7 @@ function PullRequestsRouteView() {
1158 >1562 >
1159 <PullRequestDetailPanel1563 <PullRequestDetailPanel
1160 key={activePullRequestSurface.id}1564 key={activePullRequestSurface.id}
1161 environmentId={pullRequestEnvironmentId}1565 environmentId={panelEnvironmentId}
1162 reference={{1566 reference={{
1163 projectId: activePullRequestSurface.projectId as ProjectId,1567 projectId: activePullRequestSurface.projectId as ProjectId,
1164 repository: activePullRequestSurface.repository,1568 repository: activePullRequestSurface.repository,
apps/web/src/state/pullRequests.ts+124
...@@ -1,5 +1,129 @@...@@ -1,5 +1,129 @@
1import { useAtomValue } from "@effect/atom-react";
1import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests";2import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests";
3import type {
4 EnvironmentId,
5 PullRequestListInput,
6 PullRequestListStatsInput,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";
10import { useCallback, useMemo } from "react";
211
3import { connectionAtomRuntime } from "../connection/runtime";12import { connectionAtomRuntime } from "../connection/runtime";
13import { appAtomRegistry } from "../rpc/atomRegistry";
14import {
15 mergePullRequestLists,
16 type EnvironmentPullRequestStat,
17 type MergedPullRequestList,
18} from "../components/pullRequest/pullRequestList.logic";
19import { formatEnvironmentQueryError } from "./query";
420
5export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);21export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
22
23export interface EnvironmentQueryTarget<Input> {
24 readonly environmentId: EnvironmentId;
25 readonly input: Input;
26}
27
28interface MergedEnvironmentQueryView<A> {
29 /** One entry per environment that has answered, in the order the targets were given. */
30 readonly values: ReadonlyArray<readonly [EnvironmentId, A]>;
31 /** The first environment that failed. Others may still have answered — this is not fatal. */
32 readonly error: string | null;
33 readonly isPending: boolean;
34}
35
36/**
37 * The same per-environment query read across several environments at once. React cannot subscribe
38 * to a list of atoms whose length changes, so the fan-out happens inside one derived atom keyed by
39 * the targets — the same shape the cross-environment thread search uses.
40 *
41 * An environment that fails contributes nothing rather than blanking the page: the pull request
42 * list is a union, and one unreachable machine should not hide the others' rows.
43 */
44function createMergedEnvironmentQuery<Input, A>(
45 label: string,
46 atomFor: (
47 target: EnvironmentQueryTarget<Input>,
48 ) => Atom.Atom<AsyncResult.AsyncResult<A, unknown>>,
49) {
50 const family = Atom.family((key: string) =>
51 Atom.make((get): MergedEnvironmentQueryView<A> => {
52 const targets = JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>;
53 const values: Array<readonly [EnvironmentId, A]> = [];
54 let error: string | null = null;
55 let isPending = false;
56 for (const target of targets) {
57 const result = get(atomFor(target));
58 isPending ||= result.waiting;
59 if (result._tag === "Failure" && error === null) {
60 error = formatEnvironmentQueryError(result.cause);
61 }
62 const value = Option.getOrNull(AsyncResult.value(result));
63 if (value !== null) values.push([target.environmentId, value]);
64 }
65 return { values, error, isPending };
66 }).pipe(Atom.withLabel(`${label}:${key}`)),
67 );
68 const empty = Atom.make<MergedEnvironmentQueryView<A>>({
69 values: [],
70 error: null,
71 isPending: false,
72 }).pipe(Atom.withLabel(`${label}:empty`));
73 return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) {
74 const key = JSON.stringify(targets);
75 const view = useAtomValue(targets.length === 0 ? empty : family(key));
76 const refresh = useCallback(() => {
77 for (const target of JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>) {
78 appAtomRegistry.refresh(atomFor(target));
79 }
80 }, [key]);
81 return { ...view, refresh };
82 };
83}
84
85const usePullRequestListsQuery = createMergedEnvironmentQuery(
86 "web-pull-requests:list",
87 pullRequestEnvironment.list,
88);
89
90const usePullRequestStatsQuery = createMergedEnvironmentQuery(
91 "web-pull-requests:list-stats",
92 pullRequestEnvironment.listStats,
93);
94
95export interface MergedPullRequestListView {
96 readonly data: MergedPullRequestList | null;
97 readonly error: string | null;
98 readonly isPending: boolean;
99 readonly refresh: () => void;
100}
101
102/** One listing per environment, merged into the single list the page renders. */
103export function usePullRequestList(
104 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
105): MergedPullRequestListView {
106 const query = usePullRequestListsQuery(targets);
107 const data = useMemo(() => mergePullRequestLists(query.values), [query.values]);
108 return { data, error: query.error, isPending: query.isPending, refresh: query.refresh };
109}
110
111/** The line counts for the rows on screen, asked of each environment for its own rows. */
112export function usePullRequestListStats(
113 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
114): {
115 readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null;
116 readonly refresh: () => void;
117} {
118 const query = usePullRequestStatsQuery(targets);
119 const stats = useMemo(
120 () =>
121 query.values.length === 0
122 ? null
123 : query.values.flatMap(([environmentId, result]) =>
124 result.stats.map((stat) => ({ ...stat, environmentId })),
125 ),
126 [query.values],
127 );
128 return { stats, refresh: query.refresh };
129}
apps/web/src/state/query.ts+2-2
...@@ -14,7 +14,7 @@ export interface EnvironmentQueryView<A> {...@@ -14,7 +14,7 @@ export interface EnvironmentQueryView<A> {
14 readonly refresh: () => void;14 readonly refresh: () => void;
15}15}
1616
17function formatError(cause: Cause.Cause<unknown>): string {17export function formatEnvironmentQueryError(cause: Cause.Cause<unknown>): string {
18 const error = Cause.squash(cause);18 const error = Cause.squash(cause);
19 return error instanceof Error && error.message.trim().length > 019 return error instanceof Error && error.message.trim().length > 0
20 ? error.message20 ? error.message
...@@ -29,7 +29,7 @@ export function useEnvironmentQuery<A, E>(...@@ -29,7 +29,7 @@ export function useEnvironmentQuery<A, E>(
29 const refresh = useAtomRefresh(selectedAtom);29 const refresh = useAtomRefresh(selectedAtom);
30 return {30 return {
31 data: Option.getOrNull(AsyncResult.value(result)),31 data: Option.getOrNull(AsyncResult.value(result)),
32 error: result._tag === "Failure" ? formatError(result.cause) : null,32 error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
33 isPending: atom !== null && result.waiting,33 isPending: atom !== null && result.waiting,
34 refresh,34 refresh,
35 };35 };
docs/user/source-control.md+8
...@@ -44,6 +44,14 @@ T3 Code works with the platforms your team already uses:...@@ -44,6 +44,14 @@ T3 Code works with the platforms your team already uses:
44- Open the review directly in your browser with one click44- Open the review directly in your browser with one click
45- Check out a teammate's branch to review code locally45- Check out a teammate's branch to review code locally
4646
47**Fix what you wrote, in place**
48
49- Rewrite a pull request's title and description from the review itself, in Markdown, with a
50 preview before you save
51- Rewrite your own comments the same way, wherever they are shown
52- Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its
53 comments stay read-only here, as they already were
54
47### Know Your Setup at a Glance55### Know Your Setup at a Glance
4856
49The **Source Control settings** page shows you exactly what's connected:57The **Source Control settings** page shows you exactly what's connected:
packages/client-runtime/src/state/pullRequests.ts+18
...@@ -102,12 +102,24 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -102,12 +102,24 @@ export function createPullRequestEnvironmentAtoms<R, E>(
102 scheduler: commandScheduler,102 scheduler: commandScheduler,
103 concurrency: serialPerEnvironment,103 concurrency: serialPerEnvironment,
104 }),104 }),
105 update: createEnvironmentRpcCommand(runtime, {
106 label: "environment-data:pull-requests:update",
107 tag: WS_METHODS.pullRequestsUpdate,
108 scheduler: commandScheduler,
109 concurrency: serialPerEnvironment,
110 }),
105 comment: createEnvironmentRpcCommand(runtime, {111 comment: createEnvironmentRpcCommand(runtime, {
106 label: "environment-data:pull-requests:comment",112 label: "environment-data:pull-requests:comment",
107 tag: WS_METHODS.pullRequestsComment,113 tag: WS_METHODS.pullRequestsComment,
108 scheduler: commandScheduler,114 scheduler: commandScheduler,
109 concurrency: serialPerEnvironment,115 concurrency: serialPerEnvironment,
110 }),116 }),
117 updateComment: createEnvironmentRpcCommand(runtime, {
118 label: "environment-data:pull-requests:update-comment",
119 tag: WS_METHODS.pullRequestsUpdateComment,
120 scheduler: commandScheduler,
121 concurrency: serialPerEnvironment,
122 }),
111 submitReview: createEnvironmentRpcCommand(runtime, {123 submitReview: createEnvironmentRpcCommand(runtime, {
112 label: "environment-data:pull-requests:submit-review",124 label: "environment-data:pull-requests:submit-review",
113 tag: WS_METHODS.pullRequestsSubmitReview,125 tag: WS_METHODS.pullRequestsSubmitReview,
...@@ -143,6 +155,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -143,6 +155,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(
143 scheduler: commandScheduler,155 scheduler: commandScheduler,
144 concurrency: serialPerEnvironment,156 concurrency: serialPerEnvironment,
145 }),157 }),
158 setReaction: createEnvironmentRpcCommand(runtime, {
159 label: "environment-data:pull-requests:set-reaction",
160 tag: WS_METHODS.pullRequestsSetReaction,
161 scheduler: commandScheduler,
162 concurrency: serialPerEnvironment,
163 }),
146 /**164 /**
147 * Explicit refresh: forget the server's cached answers, then re-run the reads. A separate165 * Explicit refresh: forget the server's cached answers, then re-run the reads. A separate
148 * request rather than a flag on a read, so only a person's refresh spends host requests166 * request rather than a flag on a read, so only a person's refresh spends host requests
packages/contracts/src/environmentHttp.test.ts created+62
...@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 EnvironmentAuthInvalidError,
5 EnvironmentInternalError,
6 EnvironmentOperationForbiddenError,
7 EnvironmentRequestInvalidError,
8 EnvironmentResourceNotFoundError,
9 EnvironmentScopeRequiredError,
10} from "./environmentHttp.ts";
11
12const traceId = "trace-1";
13
14describe("environment HTTP errors", () => {
15 // A client squashes the cause and shows `message`; an empty one becomes a generic
16 // "The environment request failed." that names nothing the reader can act on.
17 it("each carries a message that names its reason", () => {
18 const errors = [
19 new EnvironmentRequestInvalidError({
20 code: "invalid_request",
21 reason: "invalid_command",
22 traceId,
23 }),
24 new EnvironmentAuthInvalidError({
25 code: "auth_invalid",
26 reason: "missing_credential",
27 traceId,
28 }),
29 new EnvironmentScopeRequiredError({
30 code: "insufficient_scope",
31 requiredScope: "orchestration:read",
32 traceId,
33 }),
34 new EnvironmentOperationForbiddenError({
35 code: "operation_forbidden",
36 reason: "current_session_revoke_not_allowed",
37 traceId,
38 }),
39 new EnvironmentResourceNotFoundError({
40 code: "not_found",
41 reason: "thread_not_found",
42 traceId,
43 }),
44 new EnvironmentInternalError({
45 code: "internal_error",
46 reason: "orchestration_snapshot_failed",
47 traceId,
48 }),
49 ] as const;
50 const details = [
51 "invalid_command",
52 "missing_credential",
53 "orchestration:read",
54 "current_session_revoke_not_allowed",
55 "thread_not_found",
56 "orchestration_snapshot_failed",
57 ];
58 errors.forEach((error, index) => {
59 expect(error.message).toContain(details[index]);
60 });
61 });
62});
packages/contracts/src/environmentHttp.ts+24
...@@ -106,6 +106,10 @@ export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass<Envi...@@ -106,6 +106,10 @@ export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass<Envi
106 [HttpServerRespondable.symbol]() {106 [HttpServerRespondable.symbol]() {
107 return HttpServerResponse.schemaJson(EnvironmentRequestInvalidError)(this, { status: 400 });107 return HttpServerResponse.schemaJson(EnvironmentRequestInvalidError)(this, { status: 400 });
108 }108 }
109
110 override get message(): string {
111 return `The environment rejected the request (${this.reason}).`;
112 }
109}113}
110114
111export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()(115export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()(
...@@ -120,6 +124,10 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<Environ...@@ -120,6 +124,10 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<Environ
120 [HttpServerRespondable.symbol]() {124 [HttpServerRespondable.symbol]() {
121 return HttpServerResponse.schemaJson(EnvironmentAuthInvalidError)(this, { status: 401 });125 return HttpServerResponse.schemaJson(EnvironmentAuthInvalidError)(this, { status: 401 });
122 }126 }
127
128 override get message(): string {
129 return `The environment rejected this client's credentials (${this.reason}).`;
130 }
123}131}
124132
125export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()(133export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()(
...@@ -134,6 +142,10 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<Envir...@@ -134,6 +142,10 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<Envir
134 [HttpServerRespondable.symbol]() {142 [HttpServerRespondable.symbol]() {
135 return HttpServerResponse.schemaJson(EnvironmentScopeRequiredError)(this, { status: 403 });143 return HttpServerResponse.schemaJson(EnvironmentScopeRequiredError)(this, { status: 403 });
136 }144 }
145
146 override get message(): string {
147 return `This request needs the ${this.requiredScope} scope, which this client does not have.`;
148 }
137}149}
138150
139export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()(151export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()(
...@@ -148,6 +160,10 @@ export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<...@@ -148,6 +160,10 @@ export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<
148 [HttpServerRespondable.symbol]() {160 [HttpServerRespondable.symbol]() {
149 return HttpServerResponse.schemaJson(EnvironmentOperationForbiddenError)(this, { status: 403 });161 return HttpServerResponse.schemaJson(EnvironmentOperationForbiddenError)(this, { status: 403 });
150 }162 }
163
164 override get message(): string {
165 return `The environment refused this operation (${this.reason}).`;
166 }
151}167}
152168
153export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()(169export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()(
...@@ -162,6 +178,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass<Environmen...@@ -162,6 +178,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass<Environmen
162 [HttpServerRespondable.symbol]() {178 [HttpServerRespondable.symbol]() {
163 return HttpServerResponse.schemaJson(EnvironmentInternalError)(this, { status: 500 });179 return HttpServerResponse.schemaJson(EnvironmentInternalError)(this, { status: 500 });
164 }180 }
181
182 override get message(): string {
183 return `The environment failed to answer this request (${this.reason}).`;
184 }
165}185}
166186
167export const EnvironmentResourceNotFoundReason = Schema.Literals(["thread_not_found"]);187export const EnvironmentResourceNotFoundReason = Schema.Literals(["thread_not_found"]);
...@@ -179,6 +199,10 @@ export class EnvironmentResourceNotFoundError extends Schema.TaggedErrorClass<En...@@ -179,6 +199,10 @@ export class EnvironmentResourceNotFoundError extends Schema.TaggedErrorClass<En
179 [HttpServerRespondable.symbol]() {199 [HttpServerRespondable.symbol]() {
180 return HttpServerResponse.schemaJson(EnvironmentResourceNotFoundError)(this, { status: 404 });200 return HttpServerResponse.schemaJson(EnvironmentResourceNotFoundError)(this, { status: 404 });
181 }201 }
202
203 override get message(): string {
204 return `The environment could not find what this request named (${this.reason}).`;
205 }
182}206}
183207
184export const EnvironmentHttpCommonError = Schema.Union([208export const EnvironmentHttpCommonError = Schema.Union([
packages/contracts/src/pullRequest.test.ts+77
...@@ -2,9 +2,12 @@ import * as Schema from "effect/Schema";...@@ -2,9 +2,12 @@ import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 PullRequestActionInput,
6 PullRequestCapabilities,
5 PullRequestListInput,7 PullRequestListInput,
6 PullRequestListResult,8 PullRequestListResult,
7 PullRequestReviewerRequestInput,9 PullRequestReviewerRequestInput,
10 resolvePullRequestAuthorFilter,
8} from "./pullRequest.ts";11} from "./pullRequest.ts";
912
10const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);13const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
...@@ -153,3 +156,77 @@ describe("PullRequestReviewerRequestInput", () => {...@@ -153,3 +156,77 @@ describe("PullRequestReviewerRequestInput", () => {
153 ).toEqual(["user", "team"]);156 ).toEqual(["user", "team"]);
154 });157 });
155});158});
159
160describe("updating a branch that has fallen behind its base", () => {
161 const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
162 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
163
164 it("carries the way the branch should be brought up to date", () => {
165 expect(decodeAction({ ...ref, action: "update-branch", updateMethod: "rebase" })).toMatchObject(
166 {
167 action: "update-branch",
168 updateMethod: "rebase",
169 },
170 );
171 });
172
173 it("takes the action without a method, which is the host's own default", () => {
174 expect(decodeAction({ ...ref, action: "update-branch" }).updateMethod).toBeUndefined();
175 });
176
177 it("refuses a way no host offers", () => {
178 expect(() =>
179 decodeAction({ ...ref, action: "update-branch", updateMethod: "squash" }),
180 ).toThrow();
181 });
182});
183
184describe("leaving a merge for the host to make once it is ready", () => {
185 const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
186 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
187
188 it("carries the strategy the deferred merge should use, as merging now does", () => {
189 expect(
190 decodeAction({ ...ref, action: "enable-auto-merge", mergeMethod: "squash" }),
191 ).toMatchObject({ action: "enable-auto-merge", mergeMethod: "squash" });
192 });
193
194 it("takes the arming back without a strategy, because there is nothing to choose", () => {
195 expect(decodeAction({ ...ref, action: "disable-auto-merge" }).mergeMethod).toBeUndefined();
196 });
197});
198
199describe("PullRequestCapabilities", () => {
200 const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities);
201 const base = {
202 diff: true,
203 comment: true,
204 actions: [],
205 mergeMethods: [],
206 search: true,
207 review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
208 reviewers: { request: true, listCandidates: true },
209 };
210
211 it("decodes a server that says nothing about reactions as a server with none", () => {
212 expect(decodeCapabilities(base).reactions).toBeUndefined();
213 });
214});
215
216describe("naming the reader as the author to narrow by", () => {
217 it("reads me as whoever is signed in, however it is written", () => {
218 expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat");
219 expect(resolvePullRequestAuthorFilter("@me", "octocat")).toBe("octocat");
220 expect(resolvePullRequestAuthorFilter(" ME ", "octocat")).toBe("octocat");
221 });
222
223 it("leaves any other name as it was typed", () => {
224 expect(resolvePullRequestAuthorFilter(" mercedes ", "octocat")).toBe("mercedes");
225 expect(resolvePullRequestAuthorFilter("me-too", "octocat")).toBe("me-too");
226 });
227
228 it("stands as typed where the host has not said who the reader is", () => {
229 expect(resolvePullRequestAuthorFilter("me", null)).toBe("me");
230 expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me");
231 });
232});
packages/contracts/src/pullRequest.ts+284-1
...@@ -25,15 +25,96 @@ export type PullRequestState = typeof PullRequestState.Type;...@@ -25,15 +25,96 @@ export type PullRequestState = typeof PullRequestState.Type;
25export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]);25export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]);
26export type PullRequestListState = typeof PullRequestListState.Type;26export type PullRequestListState = typeof PullRequestListState.Type;
2727
28/** Where a review stands overall, as a host that summarises its reviews reports it. */
29export const PullRequestReviewDecision = Schema.Literals([
30 "approved",
31 "changes-requested",
32 "review-required",
33]);
34export type PullRequestReviewDecision = typeof PullRequestReviewDecision.Type;
35
36/** One qualifier's value, bounded because it is written into a host's own search query. */
37const PullRequestQualifierValue = TrimmedNonEmptyString.check(Schema.isMaxLength(200));
38const PullRequestQualifierValues = Schema.Array(PullRequestQualifierValue).check(
39 Schema.isMaxLength(10),
40);
41
42/**
43 * Narrowings beyond state and involvement, each absent by default — an absent field filters
44 * nothing, which is what every listing did before there were any. Optional as a whole so a page
45 * and a server of different ages still speak to each other.
46 *
47 * `checks` is host-side only: no row carries its own check state, so a host that cannot match it
48 * answers unnarrowed rather than the page pretending to know.
49 */
50export const PullRequestListFilters = Schema.Struct({
51 draft: Schema.optional(Schema.Literals(["only", "hide"])),
52 review: Schema.optional(
53 Schema.Literals(["approved", "changes-requested", "review-required", "none"]),
54 ),
55 checks: Schema.optional(Schema.Literals(["passing", "failing"])),
56 /**
57 * Labels as GitHub's own search reads them: each group is one `label:` qualifier, a row must
58 * satisfy every group, and a group holding several names is satisfied by any one of them —
59 * `label:size:S,size:XS` finds either size. Typed rather than picked, since a project's labels
60 * are its own and no menu can list them. Bounded because each becomes a host search qualifier.
61 */
62 labels: Schema.optional(Schema.Array(PullRequestQualifierValues).check(Schema.isMaxLength(10))),
63 excludedLabels: Schema.optional(PullRequestQualifierValues),
64 /** One login, as `author:` names it. */
65 author: Schema.optional(PullRequestQualifierValue),
66});
67export type PullRequestListFilters = typeof PullRequestListFilters.Type;
68
69/** The one-glyph summary of a change request's checks, as its list row wears it. */
70export const PullRequestChecksState = Schema.Literals(["passing", "failing", "pending"]);
71export type PullRequestChecksState = typeof PullRequestChecksState.Type;
72
28export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]);73export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]);
29export type PullRequestMergeability = typeof PullRequestMergeability.Type;74export type PullRequestMergeability = typeof PullRequestMergeability.Type;
3075
31export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]);76export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]);
32export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type;77export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type;
3378
34export const PullRequestAction = Schema.Literals(["merge", "ready", "draft", "close", "reopen"]);79export const PullRequestAction = Schema.Literals([
80 "merge",
81 "ready",
82 "draft",
83 "close",
84 "reopen",
85 /** Bring the base branch's commits into this one, which is what unblocks a stale branch. */
86 "update-branch",
87 /**
88 * Hand the merge to the host: it waits for whatever it requires — a green pipeline, the
89 * approvals, an unblocked branch — and merges without anybody coming back. Separate from
90 * `merge` because the two answer different questions. `merge` is refused outright by a host
91 * that is not ready yet, and this one is for exactly that moment.
92 */
93 "enable-auto-merge",
94 /** Take the standing instruction back, which leaves the change request where it was. */
95 "disable-auto-merge",
96]);
35export type PullRequestAction = typeof PullRequestAction.Type;97export type PullRequestAction = typeof PullRequestAction.Type;
3698
99/**
100 * How a stale branch catches up with its base: a merge commit, or a rebase onto it. The two are
101 * the host's own choices, not this page's — GitHub offers both and refuses a rebase it cannot
102 * replay, so what is offered comes from the host and what is allowed comes from the viewer.
103 */
104export const PullRequestUpdateMethod = Schema.Literals(["merge", "rebase"]);
105export type PullRequestUpdateMethod = typeof PullRequestUpdateMethod.Type;
106
107/**
108 * Where the branch stands against the base it would merge into. Separate from `mergeability`,
109 * which answers a different question: a branch can be behind and still merge cleanly, and that
110 * pairing — out of date, no conflicts — is the one an update button exists for.
111 *
112 * "unknown" where the host was not asked or could not say, which is every host but GitHub and
113 * every pull request whose head repository could not be compared.
114 */
115export const PullRequestBaseComparison = Schema.Literals(["up-to-date", "behind", "unknown"]);
116export type PullRequestBaseComparison = typeof PullRequestBaseComparison.Type;
117
37export const PullRequestActor = Schema.Struct({118export const PullRequestActor = Schema.Struct({
38 login: TrimmedNonEmptyString,119 login: TrimmedNonEmptyString,
39 name: Schema.NullOr(Schema.String),120 name: Schema.NullOr(Schema.String),
...@@ -66,6 +147,39 @@ export const PullRequestCheck = Schema.Struct({...@@ -66,6 +147,39 @@ export const PullRequestCheck = Schema.Struct({
66});147});
67export type PullRequestCheck = typeof PullRequestCheck.Type;148export type PullRequestCheck = typeof PullRequestCheck.Type;
68149
150/**
151 * The reactions a remark can carry. GitHub's eight, which is also what the picker offers: GitLab
152 * accepts any emoji as an award, and the ones outside this set are read as nothing rather than
153 * shown under a name no other host would recognise.
154 */
155export const PullRequestReactionContent = Schema.Literals([
156 "thumbs-up",
157 "thumbs-down",
158 "laugh",
159 "hooray",
160 "confused",
161 "heart",
162 "rocket",
163 "eyes",
164]);
165export type PullRequestReactionContent = typeof PullRequestReactionContent.Type;
166
167/** One reaction and everyone behind it, which is what the hover on a reaction pill says. */
168export const PullRequestReaction = Schema.Struct({
169 content: PullRequestReactionContent,
170 count: PositiveInt,
171 /**
172 * Who reacted, as far as the host named them. A host that reports fewer names than it counts
173 * leaves the rest out, so this is never longer than `count` and may be shorter. When
174 * `viewerHasReacted` is true, the viewer's own login is left out of this list — the page names
175 * them "You" instead — but `count` still counts them along with everyone else.
176 */
177 actors: Schema.Array(TrimmedNonEmptyString),
178 /** The signed-in account is one of them, so pressing the pill takes the reaction back. */
179 viewerHasReacted: Schema.Boolean,
180});
181export type PullRequestReaction = typeof PullRequestReaction.Type;
182
69export const PullRequestCommentKind = Schema.Literals([183export const PullRequestCommentKind = Schema.Literals([
70 "issue-comment",184 "issue-comment",
71 "review-comment",185 "review-comment",
...@@ -82,6 +196,8 @@ export const PullRequestComment = Schema.Struct({...@@ -82,6 +196,8 @@ export const PullRequestComment = Schema.Struct({
82 url: Schema.NullOr(Schema.String),196 url: Schema.NullOr(Schema.String),
83 path: Schema.NullOr(Schema.String),197 path: Schema.NullOr(Schema.String),
84 reviewState: Schema.NullOr(Schema.String),198 reviewState: Schema.NullOr(Schema.String),
199 /** Absent from a host with no reactions at all, which is a different thing from none on this. */
200 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
85});201});
86export type PullRequestComment = typeof PullRequestComment.Type;202export type PullRequestComment = typeof PullRequestComment.Type;
87203
...@@ -103,6 +219,7 @@ export const PullRequestThreadComment = Schema.Struct({...@@ -103,6 +219,7 @@ export const PullRequestThreadComment = Schema.Struct({
103 body: Schema.String,219 body: Schema.String,
104 createdAt: IsoDateTime,220 createdAt: IsoDateTime,
105 url: Schema.NullOr(Schema.String),221 url: Schema.NullOr(Schema.String),
222 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
106});223});
107export type PullRequestThreadComment = typeof PullRequestThreadComment.Type;224export type PullRequestThreadComment = typeof PullRequestThreadComment.Type;
108225
...@@ -197,6 +314,20 @@ export const PullRequestReviewCapabilities = Schema.Struct({...@@ -197,6 +314,20 @@ export const PullRequestReviewCapabilities = Schema.Struct({
197});314});
198export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type;315export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type;
199316
317/**
318 * What a host lets be rewritten after it has been posted. The two are separate because a host can
319 * take one without the other: Azure DevOps takes a new title and description through the same
320 * command that closes a pull request, and has no way to post a remark here at all — so nothing it
321 * shows in a conversation can be rewritten either.
322 */
323export const PullRequestEditCapabilities = Schema.Struct({
324 /** The change request's own title and description can be rewritten. */
325 changeRequest: Schema.Boolean,
326 /** A remark can be rewritten by whoever wrote it. */
327 comment: Schema.Boolean,
328});
329export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type;
330
200/**331/**
201 * What a host can do about who reviews. The two are independent: a host can take a request without332 * What a host can do about who reviews. The two are independent: a host can take a request without
202 * publishing who may receive one, which is Azure DevOps.333 * publishing who may receive one, which is Azure DevOps.
...@@ -230,14 +361,33 @@ export const PullRequestCapabilities = Schema.Struct({...@@ -230,14 +361,33 @@ export const PullRequestCapabilities = Schema.Struct({
230 actions: Schema.Array(PullRequestAction),361 actions: Schema.Array(PullRequestAction),
231 /** Merge strategies the provider itself offers, before repository settings narrow them. */362 /** Merge strategies the provider itself offers, before repository settings narrow them. */
232 mergeMethods: Schema.Array(PullRequestMergeMethod),363 mergeMethods: Schema.Array(PullRequestMergeMethod),
364 /**
365 * How this host can bring a stale branch up to date. Absent where it cannot at all, which is
366 * every host that has not said otherwise — so a provider that says nothing offers nothing.
367 */
368 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),
233 /**369 /**
234 * The host can narrow a listing by free text. False means it answers unnarrowed and whoever370 * The host can narrow a listing by free text. False means it answers unnarrowed and whoever
235 * asked has to do the narrowing — which is a different promise, so the page is told rather371 * asked has to do the narrowing — which is a different promise, so the page is told rather
236 * than left to show every change request on that host as a search result.372 * than left to show every change request on that host as a search result.
237 */373 */
238 search: Schema.Boolean,374 search: Schema.Boolean,
375 /**
376 * Reactions can be read from a remark, and added to one or taken back. One flag for both:
377 * neither host here reports reactions it will not also take, and a surface that could show a
378 * pill it may never press is a surface offering nothing. Optional for the same reason as
379 * `updateMethods` and `edit`: a server that says nothing about reactions has none, which is
380 * what every server before this field was.
381 */
382 reactions: Schema.optional(Schema.Boolean),
239 review: PullRequestReviewCapabilities,383 review: PullRequestReviewCapabilities,
240 reviewers: PullRequestReviewerCapabilities,384 reviewers: PullRequestReviewerCapabilities,
385 /**
386 * What can be rewritten after the fact. Optional so a page and a server of different ages still
387 * speak to each other: a server that says nothing about rewriting is one that cannot, which is
388 * what every server before this one was.
389 */
390 edit: Schema.optional(PullRequestEditCapabilities),
241});391});
242export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;392export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;
243393
...@@ -262,6 +412,11 @@ export const PullRequestViewerPermissions = Schema.Struct({...@@ -262,6 +412,11 @@ export const PullRequestViewerPermissions = Schema.Struct({
262 verdicts: Schema.Array(PullRequestReviewVerdict),412 verdicts: Schema.Array(PullRequestReviewVerdict),
263 /** This viewer may ask somebody for a review, and take the request back again. */413 /** This viewer may ask somebody for a review, and take the request back again. */
264 requestReviewers: Schema.Boolean,414 requestReviewers: Schema.Boolean,
415 /**
416 * The ways this viewer may bring the branch up to date, narrowed from what the host offers.
417 * Absent or empty means they may not, which is also what a host with no such action says.
418 */
419 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),
265});420});
266export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;421export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;
267422
...@@ -303,6 +458,10 @@ export const PullRequestListEntry = Schema.Struct({...@@ -303,6 +458,10 @@ export const PullRequestListEntry = Schema.Struct({
303 updatedAt: IsoDateTime,458 updatedAt: IsoDateTime,
304 viewerReviewRequested: Schema.Boolean,459 viewerReviewRequested: Schema.Boolean,
305 labels: Schema.Array(PullRequestLabel),460 labels: Schema.Array(PullRequestLabel),
461 /** Absent where the host does not summarise its reviews, which is every host but GitHub. */
462 reviewDecision: Schema.optional(PullRequestReviewDecision),
463 /** Absent where the host reports no check rollup, or the change request has no checks. */
464 checksState: Schema.optional(PullRequestChecksState),
306});465});
307export type PullRequestListEntry = typeof PullRequestListEntry.Type;466export type PullRequestListEntry = typeof PullRequestListEntry.Type;
308467
...@@ -324,7 +483,14 @@ export type PullRequestListCursors = typeof PullRequestListCursors.Type;...@@ -324,7 +483,14 @@ export type PullRequestListCursors = typeof PullRequestListCursors.Type;
324export const PullRequestListInput = Schema.Struct({483export const PullRequestListInput = Schema.Struct({
325 state: PullRequestListState,484 state: PullRequestListState,
326 involvement: Schema.optional(PullRequestInvolvement),485 involvement: Schema.optional(PullRequestInvolvement),
486 filters: Schema.optional(PullRequestListFilters),
327 projectId: Schema.optional(ProjectId),487 projectId: Schema.optional(ProjectId),
488 /**
489 * Only these projects, for a client that assigns each shared repository to one of its
490 * connections and asks the others to stay quiet about it. Absent means every project, which
491 * is what every listing asked for before there were several connections to spread across.
492 */
493 projectIds: Schema.optional(Schema.Array(ProjectId).check(Schema.isMaxLength(100))),
328 /**494 /**
329 * Narrows the listing to one host, named as the host itself rather than as its provider kind:495 * Narrows the listing to one host, named as the host itself rather than as its provider kind:
330 * github.com and a GitHub Enterprise install are two accounts, and a kind cannot tell them496 * github.com and a GitHub Enterprise install are two accounts, and a kind cannot tell them
...@@ -499,6 +665,27 @@ export const PullRequestDetail = Schema.Struct({...@@ -499,6 +665,27 @@ export const PullRequestDetail = Schema.Struct({
499 labels: Schema.Array(PullRequestLabel),665 labels: Schema.Array(PullRequestLabel),
500 checks: Schema.Array(PullRequestCheck),666 checks: Schema.Array(PullRequestCheck),
501 mergeCapabilities: PullRequestMergeCapabilities,667 mergeCapabilities: PullRequestMergeCapabilities,
668 /**
669 * Who the host says the reader is, which is the one thing a conversation cannot be read without
670 * to tell the reader's own remarks from everybody else's — and rewriting a remark is offered
671 * only where the two names agree. Absent where the host could not say, which offers nothing
672 * rather than everything.
673 */
674 viewer: Schema.optional(TrimmedNonEmptyString),
675 /**
676 * Where the branch stands against its base. Optional so a host that cannot compare says
677 * nothing rather than claiming the branch is current — the page shows a banner only where the
678 * answer is "behind", and silence is not that answer.
679 */
680 baseComparison: Schema.optional(PullRequestBaseComparison),
681 /** How many commits the base is ahead by, where the host counted them. */
682 behindBy: Schema.optional(NonNegativeInt),
683 /**
684 * Whether the host is already armed to merge this on its own. Absent where the host does not
685 * report it, which is not the same as off: a page that reads silence as "not armed" offers to
686 * arm something that is already armed, and a second arming is a write nobody asked for.
687 */
688 autoMergeEnabled: Schema.optional(Schema.Boolean),
502});689});
503export type PullRequestDetail = typeof PullRequestDetail.Type;690export type PullRequestDetail = typeof PullRequestDetail.Type;
504691
...@@ -527,6 +714,11 @@ export const PullRequestActivity = Schema.Struct({...@@ -527,6 +714,11 @@ export const PullRequestActivity = Schema.Struct({
527 commentsTruncated: Schema.Boolean,714 commentsTruncated: Schema.Boolean,
528 reviewThreads: Schema.Array(PullRequestReviewThread),715 reviewThreads: Schema.Array(PullRequestReviewThread),
529 commits: Schema.Array(PullRequestCommit),716 commits: Schema.Array(PullRequestCommit),
717 /**
718 * The change request's own reactions — the ones on its description, which every host counts
719 * against the change request itself rather than against a remark in the conversation.
720 */
721 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
530});722});
531export type PullRequestActivity = typeof PullRequestActivity.Type;723export type PullRequestActivity = typeof PullRequestActivity.Type;
532724
...@@ -562,6 +754,14 @@ export const PullRequestDiffInput = Schema.Struct({...@@ -562,6 +754,14 @@ export const PullRequestDiffInput = Schema.Struct({
562});754});
563export type PullRequestDiffInput = typeof PullRequestDiffInput.Type;755export type PullRequestDiffInput = typeof PullRequestDiffInput.Type;
564756
757/** Real line counts for a file whose hunks the host withheld from the patch. */
758export const PullRequestOmittedFileStat = Schema.Struct({
759 path: TrimmedNonEmptyString,
760 additions: Schema.Number,
761 deletions: Schema.Number,
762});
763export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type;
764
565export const PullRequestDiffResult = Schema.Struct({765export const PullRequestDiffResult = Schema.Struct({
566 patch: Schema.String,766 patch: Schema.String,
567 /**767 /**
...@@ -571,6 +771,11 @@ export const PullRequestDiffResult = Schema.Struct({...@@ -571,6 +771,11 @@ export const PullRequestDiffResult = Schema.Struct({
571 truncated: Schema.Boolean,771 truncated: Schema.Boolean,
572 /** Where the next slice starts, or null once the diff is whole. */772 /** Where the next slice starts, or null once the diff is whole. */
573 nextCursor: Schema.NullOr(TrimmedNonEmptyString),773 nextCursor: Schema.NullOr(TrimmedNonEmptyString),
774 /**
775 * The host's own counts for the files whose hunks it withheld, so a file the patch cannot
776 * show still reports what changed instead of a zero the diff never had.
777 */
778 omittedFileStats: Schema.optional(Schema.Array(PullRequestOmittedFileStat)),
574});779});
575export type PullRequestDiffResult = typeof PullRequestDiffResult.Type;780export type PullRequestDiffResult = typeof PullRequestDiffResult.Type;
576781
...@@ -594,7 +799,14 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten...@@ -594,7 +799,14 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten
594export const PullRequestActionInput = Schema.Struct({799export const PullRequestActionInput = Schema.Struct({
595 ...PullRequestRef.fields,800 ...PullRequestRef.fields,
596 action: PullRequestAction,801 action: PullRequestAction,
802 /**
803 * Which strategy the merge uses, read for `merge` and for `enable-auto-merge` alike — a merge
804 * the host performs later is still a merge, and the strategy is chosen when it is armed rather
805 * than at the moment it happens. Absent means the host's own default.
806 */
597 mergeMethod: Schema.optional(PullRequestMergeMethod),807 mergeMethod: Schema.optional(PullRequestMergeMethod),
808 /** Only read for `update-branch`, where absent means the host's own default. */
809 updateMethod: Schema.optional(PullRequestUpdateMethod),
598});810});
599export type PullRequestActionInput = typeof PullRequestActionInput.Type;811export type PullRequestActionInput = typeof PullRequestActionInput.Type;
600812
...@@ -610,6 +822,40 @@ export const PullRequestCommentInput = Schema.Struct({...@@ -610,6 +822,40 @@ export const PullRequestCommentInput = Schema.Struct({
610});822});
611export type PullRequestCommentInput = typeof PullRequestCommentInput.Type;823export type PullRequestCommentInput = typeof PullRequestCommentInput.Type;
612824
825/**
826 * A change request's own words rewritten: its title, its description, or both. Each is optional
827 * because the page rewrites them apart — a title corrected on its own must not carry a description
828 * nobody opened — and the service refuses a request that carries neither.
829 *
830 * The title is bounded far past what any host takes, GitHub refusing one past 256 characters, so
831 * an oversized one is turned away before it reaches a subprocess. The body is not trimmed for the
832 * same reason a comment is not: it is markdown, where leading spaces open a code block. An empty
833 * one is allowed, which is how a description is cleared.
834 */
835export const PullRequestUpdateInput = Schema.Struct({
836 ...PullRequestRef.fields,
837 title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))),
838 body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))),
839});
840export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type;
841
842/**
843 * A remark already posted, rewritten by whoever wrote it. The kind travels beside the id because
844 * an id alone does not say which endpoint addresses it: GitHub rewrites a comment on the
845 * conversation and a comment on a line through two different mutations.
846 *
847 * A review's own summary is not among them. The hosts disagree about what one even is — the entry
848 * Bitbucket shows is a vote this page renders as a remark, with no words behind it to rewrite —
849 * and a control that worked on one host and failed on another is worse than one that is not there.
850 */
851export const PullRequestCommentUpdateInput = Schema.Struct({
852 ...PullRequestRef.fields,
853 commentId: TrimmedNonEmptyString,
854 kind: Schema.Literals(["issue-comment", "review-comment"]),
855 body: CommentBody,
856});
857export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type;
858
613/** One remark in a review that has not been sent yet, anchored to a line of the diff. */859/** One remark in a review that has not been sent yet, anchored to a line of the diff. */
614export const PullRequestReviewCommentDraft = Schema.Struct({860export const PullRequestReviewCommentDraft = Schema.Struct({
615 path: TrimmedNonEmptyString,861 path: TrimmedNonEmptyString,
...@@ -653,6 +899,23 @@ export const PullRequestThreadResolutionInput = Schema.Struct({...@@ -653,6 +899,23 @@ export const PullRequestThreadResolutionInput = Schema.Struct({
653});899});
654export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type;900export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type;
655901
902/**
903 * Reacting and taking the reaction back are one operation with `reacted` turned around, which is
904 * what pressing the same pill twice is.
905 */
906export const PullRequestReactionInput = Schema.Struct({
907 ...PullRequestRef.fields,
908 /**
909 * Which remark to react to, as it arrived in the conversation. Absent reacts to the change
910 * request itself, which is where its description's reactions live — the id a host uses for
911 * that is its own to work out.
912 */
913 subjectId: Schema.optional(TrimmedNonEmptyString),
914 content: PullRequestReactionContent,
915 reacted: Schema.Boolean,
916});
917export type PullRequestReactionInput = typeof PullRequestReactionInput.Type;
918
656/**919/**
657 * Asking for a review and taking the request back are one operation with `requested` turned920 * Asking for a review and taking the request back are one operation with `requested` turned
658 * around, and asking again somebody who has already reviewed is what a re-request is — so that is921 * around, and asking again somebody who has already reviewed is what a re-request is — so that is
...@@ -725,6 +988,26 @@ export function pullRequestHostOf(...@@ -725,6 +988,26 @@ export function pullRequestHostOf(
725 return host === undefined || host.length === 0 ? kind : host.toLowerCase();988 return host === undefined || host.length === 0 ? kind : host.toLowerCase();
726}989}
727990
991/**
992 * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either
993 * way. A host's own search would answer it, but the page and the server both re-check an author
994 * against the row's login, so the name is resolved before either comparison rather than being
995 * matched as the literal login "me".
996 *
997 * Null viewer where the host has not said who the reader is; the filter then stands as typed,
998 * which is the same answer a search for a user named "me" would give.
999 *
1000 * Shared between the server and the page so both narrow by the same login.
1001 */
1002export function resolvePullRequestAuthorFilter(
1003 author: string,
1004 viewer: string | null | undefined,
1005): string {
1006 const trimmed = author.trim();
1007 if (!/^@?me$/i.test(trimmed)) return trimmed;
1008 return viewer === null || viewer === undefined || viewer.trim().length === 0 ? trimmed : viewer;
1009}
1010
728/**1011/**
729 * What a host needs before it can be read, as a sentence to show wherever that host is reported1012 * What a host needs before it can be read, as a sentence to show wherever that host is reported
730 * as unusable — the whole page when nothing can be read, and one entry in the host switcher when1013 * as unusable — the whole page when nothing can be read, and one entry in the host switcher when
packages/contracts/src/rpc.ts+27
...@@ -71,6 +71,7 @@ import {...@@ -71,6 +71,7 @@ import {
71 PullRequestActionInput,71 PullRequestActionInput,
72 PullRequestActivity,72 PullRequestActivity,
73 PullRequestCommentInput,73 PullRequestCommentInput,
74 PullRequestCommentUpdateInput,
74 PullRequestDetail,75 PullRequestDetail,
75 PullRequestDiffFileContentsInput,76 PullRequestDiffFileContentsInput,
76 PullRequestDiffFileContentsResult,77 PullRequestDiffFileContentsResult,
...@@ -80,6 +81,7 @@ import {...@@ -80,6 +81,7 @@ import {
80 PullRequestListStatsInput,81 PullRequestListStatsInput,
81 PullRequestListStatsResult,82 PullRequestListStatsResult,
82 PullRequestOperationError,83 PullRequestOperationError,
84 PullRequestReactionInput,
83 PullRequestRef,85 PullRequestRef,
84 PullRequestReviewerCandidateList,86 PullRequestReviewerCandidateList,
85 PullRequestReviewerRequestInput,87 PullRequestReviewerRequestInput,
...@@ -87,6 +89,7 @@ import {...@@ -87,6 +89,7 @@ import {
87 PullRequestThreadReplyInput,89 PullRequestThreadReplyInput,
88 PullRequestThreadResolutionInput,90 PullRequestThreadResolutionInput,
89 PullRequestUnavailableError,91 PullRequestUnavailableError,
92 PullRequestUpdateInput,
90} from "./pullRequest.ts";93} from "./pullRequest.ts";
91import {94import {
92 RelayClientInstallFailedError,95 RelayClientInstallFailedError,
...@@ -279,10 +282,13 @@ export const WS_METHODS = {...@@ -279,10 +282,13 @@ export const WS_METHODS = {
279 pullRequestsActivity: "pullRequests.activity",282 pullRequestsActivity: "pullRequests.activity",
280 pullRequestsDiffFileContents: "pullRequests.diffFileContents",283 pullRequestsDiffFileContents: "pullRequests.diffFileContents",
281 pullRequestsRunAction: "pullRequests.runAction",284 pullRequestsRunAction: "pullRequests.runAction",
285 pullRequestsUpdate: "pullRequests.update",
282 pullRequestsComment: "pullRequests.comment",286 pullRequestsComment: "pullRequests.comment",
287 pullRequestsUpdateComment: "pullRequests.updateComment",
283 pullRequestsSubmitReview: "pullRequests.submitReview",288 pullRequestsSubmitReview: "pullRequests.submitReview",
284 pullRequestsReplyToThread: "pullRequests.replyToThread",289 pullRequestsReplyToThread: "pullRequests.replyToThread",
285 pullRequestsSetThreadResolution: "pullRequests.setThreadResolution",290 pullRequestsSetThreadResolution: "pullRequests.setThreadResolution",
291 pullRequestsSetReaction: "pullRequests.setReaction",
286 pullRequestsInvalidate: "pullRequests.invalidate",292 pullRequestsInvalidate: "pullRequests.invalidate",
287 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",293 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",
288 pullRequestsRequestReviewers: "pullRequests.requestReviewers",294 pullRequestsRequestReviewers: "pullRequests.requestReviewers",
...@@ -507,12 +513,24 @@ export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAct...@@ -507,12 +513,24 @@ export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAct
507 error: PullRequestRpcError,513 error: PullRequestRpcError,
508});514});
509515
516export const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, {
517 payload: PullRequestUpdateInput,
518 success: Schema.Void,
519 error: PullRequestRpcError,
520});
521
510export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, {522export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, {
511 payload: PullRequestCommentInput,523 payload: PullRequestCommentInput,
512 success: Schema.Void,524 success: Schema.Void,
513 error: PullRequestRpcError,525 error: PullRequestRpcError,
514});526});
515527
528export const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, {
529 payload: PullRequestCommentUpdateInput,
530 success: Schema.Void,
531 error: PullRequestRpcError,
532});
533
516export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, {534export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, {
517 payload: PullRequestSubmitReviewInput,535 payload: PullRequestSubmitReviewInput,
518 success: Schema.Void,536 success: Schema.Void,
...@@ -534,6 +552,12 @@ export const WsPullRequestsSetThreadResolutionRpc = Rpc.make(...@@ -534,6 +552,12 @@ export const WsPullRequestsSetThreadResolutionRpc = Rpc.make(
534 },552 },
535);553);
536554
555export const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, {
556 payload: PullRequestReactionInput,
557 success: Schema.Void,
558 error: PullRequestRpcError,
559});
560
537export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, {561export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, {
538 payload: PullRequestInvalidateInput,562 payload: PullRequestInvalidateInput,
539 success: Schema.Void,563 success: Schema.Void,
...@@ -976,10 +1000,13 @@ export const WsRpcGroup = RpcGroup.make(...@@ -976,10 +1000,13 @@ export const WsRpcGroup = RpcGroup.make(
976 WsPullRequestsActivityRpc,1000 WsPullRequestsActivityRpc,
977 WsPullRequestsDiffFileContentsRpc,1001 WsPullRequestsDiffFileContentsRpc,
978 WsPullRequestsRunActionRpc,1002 WsPullRequestsRunActionRpc,
1003 WsPullRequestsUpdateRpc,
979 WsPullRequestsCommentRpc,1004 WsPullRequestsCommentRpc,
1005 WsPullRequestsUpdateCommentRpc,
980 WsPullRequestsSubmitReviewRpc,1006 WsPullRequestsSubmitReviewRpc,
981 WsPullRequestsReplyToThreadRpc,1007 WsPullRequestsReplyToThreadRpc,
982 WsPullRequestsSetThreadResolutionRpc,1008 WsPullRequestsSetThreadResolutionRpc,
1009 WsPullRequestsSetReactionRpc,
983 WsPullRequestsInvalidateRpc,1010 WsPullRequestsInvalidateRpc,
984 WsPullRequestsReviewerCandidatesRpc,1011 WsPullRequestsReviewerCandidatesRpc,
985 WsPullRequestsRequestReviewersRpc,1012 WsPullRequestsRequestReviewersRpc,
packages/contracts/src/vcs.ts+10-7
...@@ -76,6 +76,7 @@ export interface VcsProcessTimeoutFailure {...@@ -76,6 +76,7 @@ export interface VcsProcessTimeoutFailure {
76export const VcsProcessExitFailureKind = Schema.Literals([76export const VcsProcessExitFailureKind = Schema.Literals([
77 "authentication",77 "authentication",
78 "not-found",78 "not-found",
79 "rate-limited",
79 "command-failed",80 "command-failed",
80]);81]);
81export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type;82export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type;
...@@ -134,13 +135,15 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitE...@@ -134,13 +135,15 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitE
134 const detail =135 const detail =
135 failureKind === "authentication"136 failureKind === "authentication"
136 ? "Authentication failed."137 ? "Authentication failed."
137 : failureKind === "not-found"138 : failureKind === "rate-limited"
138 ? context.command === "glab"139 ? "API rate limit exceeded."
139 ? "Merge request not found."140 : failureKind === "not-found"
140 : context.command === "gh" || context.command === "az"141 ? context.command === "glab"
141 ? "Pull request not found."142 ? "Merge request not found."
142 : "VCS resource not found."143 : context.command === "gh" || context.command === "az"
143 : "Process exited with a non-zero status.";144 ? "Pull request not found."
145 : "VCS resource not found."
146 : "Process exited with a non-zero status.";
144147
145 return new VcsProcessExitError({148 return new VcsProcessExitError({
146 ...context,149 ...context,