| author | |
| committer | |
| log | b28f9bf0a1bd562623c027c5ed80b5ca50395b28 |
| tree | 10832ae430e9f98c5ef5d303d9e4f9a4b997c21b |
| parent | d0b8d6306b4686528913948b21c8a9eeda01645a |
| signature | Signed by PGP key B5690EEEBB952194 |
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-only | 68 | // 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"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; | 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Logger from "effect/Logger"; | ||
| 12 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 13 | import * as PlatformError from "effect/PlatformError"; | 14 | import * as PlatformError from "effect/PlatformError"; |
| 15 | import * as References from "effect/References"; | ||
| 14 | import * as Scope from "effect/Scope"; | 16 | import * as Scope from "effect/Scope"; |
| 15 | import { ChildProcessSpawner } from "effect/unstable/process"; | 17 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | import { expect } from "vite-plus/test"; | 18 | import { 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 | ); |
| 1388 | 1390 | ||
| 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"; |
| 12 | import * as Order from "effect/Order"; | 12 | import * as Order from "effect/Order"; |
| 13 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 14 | import * as Ref from "effect/Ref"; | 14 | import * as Ref from "effect/Ref"; |
| 15 | import * as Schema from "effect/Schema"; | ||
| 15 | import { | 16 | import { |
| 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"; |
| 33 | import { | 35 | import { |
| ... | @@ -113,6 +115,7 @@ const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); | ... | @@ -113,6 +115,7 @@ const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); |
| 113 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); | 115 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); |
| 114 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); | 116 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); |
| 115 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; | 117 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; |
| 118 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); | ||
| 116 | 119 | ||
| 117 | /** | 120 | /** |
| 118 | * How long a failed PR lookup is cached, given the number of consecutive | 121 | * 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 error | 1051 | 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 | ); |
| 344 | 344 | ||
| 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 | ); |
| 372 | 405 | ||
| 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>; |
| 161 | 161 | ||
| 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 whole | 171 | * 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 that | 172 | * 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 | }); |
| 486 | 526 |
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 it | 9 | // 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 can | 18 | // 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 being | 39 | // 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 | }; |
| 34 | 47 | ||
| 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"))), |
| 208 | 222 | ||
| 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 the | 233 | // 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"), |
| 241 | 265 | ||
| 242 | setThreadResolution: () => unsupported("setThreadResolution"), | 266 | setThreadResolution: () => unsupported("setThreadResolution"), |
| 267 | |||
| 268 | setReaction: () => unsupported("setReaction"), | ||
| 243 | }; | 269 | }; |
| 244 | 270 | ||
| 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 | ); |
| 512 | 512 | ||
| 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>; |
| 224 | 224 | ||
| 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>; |
| 230 | 237 | ||
| 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 | }), |
| 703 | 717 | ||
| 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 | bitbucket | 738 | 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 | ), |
| 715 | 747 | ||
| 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 | }; |
| 30 | 34 | ||
| 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"))), |
| 251 | 255 | ||
| 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 | api | 267 | 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"))), |
| 256 | 270 | ||
| 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 | api | 282 | 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"))), |
| 277 | 301 | ||
| 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 | api | 314 | 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"; |
| 5 | 5 | ||
| 6 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 6 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 7 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 7 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 8 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; | ||
| 8 | 9 | ||
| 9 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); | 10 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); |
| 10 | 11 | ||
| ... | @@ -551,6 +552,206 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -551,6 +552,206 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 551 | }), | 552 | }), |
| 552 | ); | 553 | ); |
| 553 | 554 | ||
| 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 | ); |
| 821 | 1022 | ||
| 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 | ); |
| 846 | 1081 | ||
| 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 | ); |
| 1408 | 1711 | ||
| 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 | ); |
| 1636 | 2212 | ||
| 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"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import type { | 6 | import { |
| 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"; |
| 18 | 24 | ||
| 19 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 25 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 20 | import { | 26 | import { |
| 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 | } |
| 201 | 222 | ||
| 223 | /** Not a decode failure: the reader named a subject this pull request never handed out. */ | ||
| 224 | export 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 | |||
| 202 | export type GitHubPullRequestCliError = | 241 | export type GitHubPullRequestCliError = |
| 203 | | GitHubCli.GitHubCliError | 242 | | GitHubCli.GitHubCliError |
| 204 | | GitHubPullRequestReadError | 243 | | GitHubPullRequestReadError |
| ... | @@ -207,6 +246,7 @@ export type GitHubPullRequestCliError = | ... | @@ -207,6 +246,7 @@ export type GitHubPullRequestCliError = |
| 207 | | GitHubDiffRevisionsUnavailableError | 246 | | GitHubDiffRevisionsUnavailableError |
| 208 | | GitHubDiffFileContentsUnavailableError | 247 | | GitHubDiffFileContentsUnavailableError |
| 209 | | GitHubRepositorySelectorError | 248 | | GitHubRepositorySelectorError |
| 249 | | GitHubSubjectScopeError | ||
| 210 | | GitHubViewerLoginUnavailableError; | 250 | | GitHubViewerLoginUnavailableError; |
| 211 | 251 | ||
| 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 | } |
| 274 | 316 | ||
| 275 | export class GitHubPullRequestCli extends Context.Service< | 317 | export 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>; |
| 295 | 339 | ||
| 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>; |
| 313 | 358 | ||
| 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>; |
| 330 | 375 | ||
| 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>; |
| 422 | 482 | ||
| 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") {} |
| 458 | 559 | ||
| ... | @@ -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 | } |
| 503 | 604 | ||
| 605 | /** GitHub's own spelling of a review state, which is not the contract's. */ | ||
| 606 | const 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 | */ | ||
| 618 | function qualifierValue(value: string): string { | ||
| 619 | return `"${value.replaceAll('"', "").trim()}"`; | ||
| 620 | } | ||
| 621 | |||
| 622 | function 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 | */ | ||
| 652 | function 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 | |||
| 504 | function involvementArgs(input: { | 675 | function 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 excludes | 689 | // `--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 in | 707 | // `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 and | 708 | // 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() === viewer | 734 | ? 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 | } |
| 565 | 739 | ||
| 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] { |
| 621 | function actionArgs( | 797 | function 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( |
| 639 | export const make = Effect.gen(function* () { | 825 | export const make = Effect.gen(function* () { |
| 640 | const github = yield* GitHubCli.GitHubCli; | 826 | const github = yield* GitHubCli.GitHubCli; |
| 641 | 827 | ||
| 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 is | 884 | // `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 own | 885 | // 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 fallback | 1222 | // 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 the | 1223 | // 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 && !searched | 1233 | 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 | ), |
| 1087 | 1337 | ||
| 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 | github | 1356 | 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); |
| 1239 | 1515 | ||
| 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 whole | 1543 | // 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 even | 1572 | // 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 | }, |
| 1397 | 1703 | ||
| 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 github | 1710 | 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 | }); |
| 1472 | 1867 |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+230-2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import type { PullRequestReaction } from "@t3tools/contracts"; | ||
| 4 | 5 | ||
| 5 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 6 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 6 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; | 7 | import { 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"; |
| 9 | describe("gitHubViewerPermissions", () => { | 10 | describe("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", () => { |
| 34 | 44 | ||
| 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 | }); |
| 106 | 119 | ||
| 120 | describe("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 | |||
| 107 | describe("getChangeRequest commits", () => { | 215 | describe("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", () => { |
| 133 | 242 | ||
| 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 | }); |
| 202 | 314 | ||
| 315 | describe("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 | |||
| 377 | describe("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 | |||
| 203 | describe("loginAvatarUrl", () => { | 431 | describe("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"; |
| 2 | import type { | 2 | import type { |
| 3 | PullRequestActor, | 3 | PullRequestActor, |
| 4 | PullRequestCapabilities, | 4 | PullRequestCapabilities, |
| 5 | PullRequestReaction, | ||
| 5 | PullRequestViewerPermissions, | 6 | PullRequestViewerPermissions, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | ||
| ... | @@ -17,9 +18,20 @@ import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; | ... | @@ -17,9 +18,20 @@ import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; |
| 17 | const CAPABILITIES: PullRequestCapabilities = { | 18 | const 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 | }; |
| 31 | 44 | ||
| 32 | /** | 45 | /** |
| ... | @@ -49,8 +62,13 @@ const CAPABILITIES: PullRequestCapabilities = { | ... | @@ -49,8 +62,13 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 49 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { | 62 | export 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 | } |
| 64 | 83 | ||
| ... | @@ -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 | } |
| 100 | 119 | ||
| 120 | /** True where markdown would render nothing: whitespace, or only HTML comments. */ | ||
| 121 | const rendersEmpty = (body: string): boolean => | ||
| 122 | body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0; | ||
| 123 | |||
| 101 | export const make = Effect.gen(function* () { | 124 | export const make = Effect.gen(function* () { |
| 102 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 125 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 103 | 126 | ||
| ... | @@ -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 > 0 | 314 | commits: (reviewThreads.commits.length > 0 |
| 257 | ? reviewThreads.commits | 315 | ? reviewThreads.commits |
| 258 | : pullRequest.commits | 316 | : 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 two | 343 | // `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 | ), |
| 286 | 357 | ||
| 287 | getViewerPermissions: (input) => | 358 | getViewerPermissions: (input) => |
| 288 | cli | 359 | 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 | ), | ||
| 291 | 387 | ||
| 292 | getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), | 388 | getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), |
| 293 | 389 | ||
| ... | @@ -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"))), |
| 323 | 420 | ||
| 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"))), |
| 325 | 434 | ||
| 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"))), |
| 327 | 449 | ||
| 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"))), |
| 338 | 460 | ||
| 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 | cli | 475 | 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 | ); |
| 416 | 416 | ||
| 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 | ); |
| 793 | 856 | ||
| 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 | ); |
| 1010 | 1088 | ||
| 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 { |
| 19 | 21 | ||
| 20 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 22 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 21 | import { | 23 | import { |
| 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>; |
| 305 | 311 | ||
| 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>; |
| 312 | 327 | ||
| 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") {} |
| 348 | 397 | ||
| ... | @@ -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 | ); |
| 864 | 935 | ||
| 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 | ), | ||
| 885 | 1034 | ||
| 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* () { |
| 893 | 1042 | ||
| 894 | listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), | 1043 | listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), |
| 895 | 1044 | ||
| 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 | ), |
| 1054 | 1241 | ||
| 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 gitlab | 1254 | 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 | }, |
| 1064 | 1261 | ||
| 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), |
| 1074 | 1285 | ||
| 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: [] }), |
| 1076 | 1297 | ||
| 1077 | submitReview: (input) => | 1298 | submitReview: (input) => |
apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts+161-4| ... | @@ -1,20 +1,36 @@ | ... | @@ -1,20 +1,36 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { assert, describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 2 | 4 | ||
| 3 | import { gitLabViewerPermissions } from "./GitLabPullRequestProvider.ts"; | 5 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; |
| 6 | import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; | ||
| 4 | 7 | ||
| 5 | describe("gitLabViewerPermissions", () => { | 8 | describe("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 | }); |
| 16 | 32 | ||
| 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 target | 34 | // `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 | }); |
| 28 | 44 | ||
| 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 beyond | 52 | // 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 taken | 53 | // `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 | |||
| 64 | describe("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 | |||
| 140 | describe("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | 2 | import type { |
| 3 | PullRequestCapabilities, | ||
| 4 | PullRequestReaction, | ||
| 5 | PullRequestViewerPermissions, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 3 | 7 | ||
| 4 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | 8 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; |
| 5 | import { | 9 | import { |
| ... | @@ -12,10 +16,24 @@ import { | ... | @@ -12,10 +16,24 @@ import { |
| 12 | const CAPABILITIES: PullRequestCapabilities = { | 16 | const 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 | }; |
| 29 | 48 | ||
| 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 | */ | ||
| 54 | const 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 per | 62 | * 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 thing | 63 | * 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 | } |
| 55 | 91 | ||
| ... | @@ -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 | cli | 175 | 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: the | 197 | // 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 ones | 198 | // 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"))), |
| 190 | 255 | ||
| 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"))), |
| 192 | 268 | ||
| 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"))), |
| 194 | 283 | ||
| 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"))), |
| 205 | 294 | ||
| 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 | cli | 308 | 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"; |
| 3 | import type { | 3 | import 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 | } |
| 68 | 80 | ||
| 69 | export interface ProviderChangeRequestPage { | 81 | export 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 | } |
| 144 | 161 | ||
| 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 | } |
| 162 | 181 | ||
| 163 | export interface ProviderDiffSlice { | 182 | export 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 | } |
| 169 | 190 | ||
| 170 | export interface ProviderDiffFileContents { | 191 | export 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>; |
| 221 | 248 | ||
| ... | @@ -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>; |
| 248 | 276 | ||
| 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>; |
| 320 | 364 | ||
| ... | @@ -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>; |
| 324 | 368 | ||
| 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 in | 387 | * 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 declared | 388 | * `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>; |
| 378 | 439 | ||
| 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" |
| 435 | 440 | ||
| 436 | const result = yield* service.list({ state: "open" }); | 441 | const result = yield* service.list({ state: "open" }); |
| 437 | 442 | ||
| 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 | ); |
| 1009 | 1016 | ||
| 1017 | it.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 | |||
| 1072 | it.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 | |||
| 1117 | it.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 | |||
| 1010 | it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => | 1147 | it.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 | ); |
| 1482 | 1625 | ||
| 1626 | it.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 | |||
| 1663 | it.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 | |||
| 1699 | it.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 | |||
| 1737 | it.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 | |||
| 1483 | it.effect("refuses an empty reply before it reaches the host", () => | 1769 | it.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 | ); |
| 1912 | 2201 | ||
| 2202 | it.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 | |||
| 1913 | it.effect("an explicit invalidation makes the next listing ask the host again", () => | 2241 | it.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 | |||
| 2702 | it.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 | |||
| 2748 | it("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 | |||
| 2763 | it("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 | |||
| 2773 | it("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 | |||
| 2785 | it.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 | |||
| 2823 | it.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 | |||
| 2870 | it.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 | |||
| 2911 | it.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 | |||
| 2960 | it.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 | |||
| 3019 | it.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 | |||
| 3068 | it.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 | |||
| 3096 | it.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 | |||
| 3114 | it.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 | |||
| 3152 | it.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 | |||
| 3184 | it.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 | |||
| 3208 | it.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 | |||
| 3248 | it.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 | }; |
| 180 | 198 | ||
| 181 | /** | 199 | /** |
| ... | @@ -349,13 +367,25 @@ function toPullRequestError( | ... | @@ -349,13 +367,25 @@ function toPullRequestError( |
| 349 | } | 367 | } |
| 350 | 368 | ||
| 351 | /** | 369 | /** |
| 352 | * The provider-native repository identity. `displayName` is the full path below the host, which | 370 | * 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-segment | 371 | * 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 | */ |
| 356 | function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { | 382 | export 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 | }; |
| 425 | 455 | ||
| 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 | ); |
| 605 | 636 | ||
| 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 | }; |
| 637 | 711 | ||
| ... | @@ -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 all | 829 | // 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 the | 833 | // 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 === undefined | 835 | ...(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 === undefined | 917 | ...(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 | }); |
| 941 | 1021 | ||
| 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.api | 1033 | 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 | ); |
| 989 | 1090 | ||
| ... | @@ -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 both | 1206 | // 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 checks | 1207 | // 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.api | 1230 | 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 | ); |
| 1164 | 1291 | ||
| 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 | ); |
| 1299 | 1496 | ||
| 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 is | 1528 | * 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 open | 1529 | * 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 | }; |
| 1522 | 1749 | ||
| 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 holder | 1767 | // 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 one | 1769 | // 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 | }); |
| 146 | 146 | ||
| 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()))); |
| 149 | 164 |
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 | } |
| 128 | 136 | ||
| 129 | function trimmed(value: string | null | undefined): string | null { | 137 | function 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 | } |
| 228 | 237 |
apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts+16| ... | @@ -315,6 +315,22 @@ describe("decodeStatusesJson", () => { | ... | @@ -315,6 +315,22 @@ describe("decodeStatusesJson", () => { |
| 315 | 315 | ||
| 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 | }); |
| 319 | 335 | ||
| 320 | describe("decodeDiffstatJson", () => { | 336 | describe("decodeDiffstatJson", () => { |
apps/server/src/pullRequest/bitbucketPullRequestJson.ts+20-6| ... | @@ -18,6 +18,8 @@ import type { | ... | @@ -18,6 +18,8 @@ import type { |
| 18 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | 18 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 19 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 19 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 20 | 20 | ||
| 21 | import { dedupeChecks } from "./pullRequestChecks.ts"; | ||
| 22 | |||
| 21 | /** | 23 | /** |
| 22 | * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant | 24 | * 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 not | 25 | * 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 | } |
| 566 | 580 | ||
| 567 | export interface BitbucketDiffStat { | 581 | export 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"; |
| 4 | import { | 4 | import { |
| 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"; |
| 18 | 22 | ||
| 19 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | 23 | function 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 | }); |
| 69 | 73 | ||
| 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 | }); |
| 77 | 140 | ||
| 141 | describe("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 | |||
| 78 | describe("pull request detail decoding", () => { | 184 | describe("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 | }); |
| 119 | 225 | ||
| 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 | }); |
| 463 | 621 | ||
| 622 | describe("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 | |||
| 464 | describe("repository access decoding", () => { | 705 | describe("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 | |||
| 999 | describe("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 | |||
| 1011 | describe("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 | }); |
| 680 | 1018 | ||
| 681 | describe("reviewer candidate decoding", () => { | 1019 | describe("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 | |||
| 1299 | describe("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"; |
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 28 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 24 | 29 | ||
| 30 | import { 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 | }); |
| 53 | 60 | ||
| 61 | const 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 | |||
| 54 | const RawListItemSchema = Schema.Struct({ | 82 | const 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 | }); |
| 72 | 109 | ||
| 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 | }); |
| 117 | 185 | ||
| 118 | const RawSearchSchema = Schema.Struct({ | 186 | const RawSearchSchema = Schema.Struct({ |
| ... | @@ -149,17 +217,109 @@ const RawStatsSchema = Schema.Struct({ | ... | @@ -149,17 +217,109 @@ const RawStatsSchema = Schema.Struct({ |
| 149 | ), | 217 | ), |
| 150 | }); | 218 | }); |
| 151 | 219 | ||
| 152 | const 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), | 221 | const 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)), | 227 | const 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. */ | ||
| 242 | const 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 | |||
| 253 | const 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 | |||
| 264 | export function gitHubReactionContent(content: PullRequestReactionContent): string { | ||
| 265 | return GITHUB_REACTION_BY_CONTENT[content]; | ||
| 266 | } | ||
| 267 | |||
| 268 | const 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 | |||
| 295 | type 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 | */ | ||
| 303 | function 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 | } | ||
| 163 | 323 | ||
| 164 | const RawCommentSchema = Schema.Struct({ | 324 | const 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 | }); |
| 171 | 333 | ||
| 172 | const RawReviewSchema = Schema.Struct({ | 334 | const RawReviewSchema = Schema.Struct({ |
| ... | @@ -196,10 +358,17 @@ const RawCommitSchema = Schema.Struct({ | ... | @@ -196,10 +358,17 @@ const RawCommitSchema = Schema.Struct({ |
| 196 | 358 | ||
| 197 | const RawDetailSchema = Schema.Struct({ | 359 | const 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 | }); |
| 204 | 373 | ||
| 205 | const RawActivitySchema = Schema.Struct({ | 374 | const 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. */ |
| 235 | const RawReviewThreadsSchema = Schema.Struct({ | 404 | const 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 | } |
| 385 | 597 | ||
| 386 | export const PULL_REQUEST_LIST_JSON_FIELDS = | 598 | export 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"; |
| 388 | 600 | ||
| 389 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`; | 601 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`; |
| 390 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; | 602 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; |
| 391 | 603 | ||
| 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 | state | 641 | state |
| 430 | isDraft | 642 | isDraft |
| 431 | mergeable | 643 | mergeable |
| 644 | reviewDecision | ||
| 432 | createdAt | 645 | createdAt |
| 433 | updatedAt | 646 | updatedAt |
| 434 | mergedAt | 647 | 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 | */ |
| 463 | export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { | 677 | export 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 | totalCount | 692 | 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 | viewerCanUpdate | 698 | viewerCanUpdate |
| 484 | viewerDidAuthor | 699 | 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 | */ |
| 519 | export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) { | 742 | export 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 |
| 529 | 753 | ||
| 530 | const RawReviewThreadCommentsSchema = Schema.Struct({ | 754 | const 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 | }`; |
| 542 | 769 | ||
| 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 | */ | ||
| 775 | export 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 | |||
| 779 | const RawPullRequestNodeIdSchema = Schema.Struct({ | ||
| 780 | data: Schema.Struct({ | ||
| 781 | repository: Schema.Struct({ | ||
| 782 | pullRequest: Schema.Struct({ id: Schema.String }), | ||
| 783 | }), | ||
| 784 | }), | ||
| 785 | }); | ||
| 786 | |||
| 787 | const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); | ||
| 788 | |||
| 789 | export 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 | */ | ||
| 801 | export 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 | |||
| 811 | const 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 | |||
| 825 | const 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 | */ | ||
| 831 | export 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 | |||
| 840 | export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | ||
| 841 | addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | ||
| 842 | }`; | ||
| 843 | |||
| 844 | export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { | ||
| 845 | removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } | ||
| 846 | }`; | ||
| 847 | |||
| 543 | export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { | 848 | export 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 | }`; |
| 550 | 855 | ||
| 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 | */ | ||
| 861 | export 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 | */ | ||
| 871 | export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { | ||
| 872 | updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } | ||
| 873 | }`; | ||
| 874 | |||
| 875 | export 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 document | 882 | * 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 | }; |
| 590 | 920 | ||
| 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 | */ | ||
| 925 | export 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. */ |
| 592 | export function buildReviewSubmissionJson(input: { | 937 | export 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 | } |
| 638 | 987 | ||
| 639 | export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { | 988 | export 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 | } |
| 646 | 999 | ||
| 647 | export interface GitHubPullRequestActivity { | 1000 | export 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 | } |
| 733 | 1086 | ||
| 1087 | function 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 | |||
| 734 | function toLabels( | 1100 | function 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 | } |
| 794 | 1160 | ||
| 795 | function toChecks( | 1161 | /** What GitHub writes where a run has not reached that moment yet, which is not a time. */ |
| 1162 | const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; | ||
| 1163 | |||
| 1164 | function 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. */ | ||
| 1170 | function 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 | */ | ||
| 1179 | function 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 | } |
| 811 | 1203 | ||
| 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 | */ | ||
| 1217 | function 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 | |||
| 1230 | function 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. */ |
| 813 | function isReviewVerdict(reviewState: string | null): boolean { | 1237 | function 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 | } |
| 906 | 1332 | ||
| 907 | function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail { | 1333 | function 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 | } |
| 917 | 1349 | ||
| ... | @@ -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( |
| 1094 | 1532 | ||
| 1095 | export interface GitHubReviewThreadComments { | 1533 | export 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 has | 1547 | * 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 on | 1548 | * 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 | } |
| 1152 | 1607 | ||
| 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 | } |
| 1176 | 1632 | ||
| 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. */ |
| 1634 | function 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 | |||
| 1651 | const 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 | |||
| 1671 | const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); | ||
| 1672 | |||
| 1673 | /** One further page of dismissal events, in the shape the thread read's own page carries. */ | ||
| 1674 | export 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 | |||
| 1178 | export function decodeReviewThreadsJson( | 1692 | export 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 | } |
| 1273 | 1803 | ||
| ... | @@ -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 refuses | 1883 | * 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 | */ | ||
| 1897 | export 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 | |||
| 1910 | const 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 | |||
| 1934 | const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema); | ||
| 1935 | |||
| 1936 | export 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 | |||
| 1942 | export 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 | |||
| 1354 | export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | 1955 | export 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 | } |
| 1496 | 2103 | ||
| 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 | } |
| 1543 | 2152 | ||
| 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, and | 2175 | // 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 hole | 2176 | // 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"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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"; |
| 12 | 15 | ||
| 13 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | 16 | function 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 | }); |
| 179 | 182 | ||
| 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 | |||
| 485 | describe("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 | |||
| 585 | describe("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 | |||
| 604 | describe("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 | }); |
| 74 | 91 | ||
| 75 | const RawNoteSchema = Schema.Struct({ | 92 | const 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 | } |
| 213 | 237 | ||
| 214 | function trimmed(value: string | null | undefined): string | null { | 238 | function trimmed(value: string | null | undefined): string | null { |
| ... | @@ -334,6 +358,10 @@ function toListItem( | ... | @@ -334,6 +358,10 @@ function toListItem( |
| 334 | 358 | ||
| 335 | function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { | 359 | function 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 | } |
| 355 | 385 | ||
| ... | @@ -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. */ | ||
| 731 | const 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 | |||
| 742 | const 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 | |||
| 747 | export 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 | */ | ||
| 758 | export 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 | |||
| 771 | const 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 | |||
| 794 | const 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 | |||
| 831 | const 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 | */ | ||
| 838 | function 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. */ | ||
| 871 | function 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 | |||
| 876 | export 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 | |||
| 883 | export 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 | |||
| 908 | const 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 | |||
| 916 | const 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 | */ | ||
| 922 | export 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 @@ | ||
| 1 | import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { dedupeChecks } from "./pullRequestChecks.ts"; | ||
| 5 | |||
| 6 | function 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 | |||
| 22 | describe("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 @@ | ||
| 1 | import type { PullRequestCheck } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ | ||
| 4 | function 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 | */ | ||
| 30 | export 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 | } |
| 53 | 53 | ||
| 54 | export 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 | |||
| 54 | export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass<GitHubPullRequestNotFoundError>()( | 67 | export 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 |
| 138 | export const GitHubCliError = Schema.Union([ | 151 | export 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 | ); |
| 142 | 142 | ||
| 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 | } |
| 71 | 71 | ||
| 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"; |
| 123 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 123 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 124 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | 124 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 125 | import { | 125 | import { |
| 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"; |
| 133 | import { | 133 | import { |
| ... | @@ -144,6 +144,7 @@ import { | ... | @@ -144,6 +144,7 @@ import { |
| 144 | selectThreadPreviewMiniPlayer, | 144 | selectThreadPreviewMiniPlayer, |
| 145 | usePreviewMiniPlayerStore, | 145 | usePreviewMiniPlayerStore, |
| 146 | } from "../previewMiniPlayerStore"; | 146 | } from "../previewMiniPlayerStore"; |
| 147 | import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; | ||
| 147 | import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; | 148 | import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; |
| 148 | import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; | 149 | import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; |
| 149 | import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; | 150 | import { 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.isDraft | 1607 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; |
| 1607 | ? current | 1608 | 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.repository | 6091 | { |
| 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 | }); |
| 43 | 43 | ||
| 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>[]; |
| 22 | 22 | ||
| 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 | */ | ||
| 29 | export 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 | |||
| 23 | interface ShowExternalLinkContextMenuOptions { | 37 | interface 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 |
| 50 | export async function showExternalLinkContextMenu({ | 66 | export 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 | }; |
| 273 | 278 | ||
| 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 | ...props | 285 | ...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 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | PullRequestCheck, | ||
| 4 | PullRequestChecksState, | ||
| 5 | PullRequestRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import { readLocalApi } from "~/localApi"; | ||
| 9 | import { cn } from "~/lib/utils"; | ||
| 10 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 11 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 12 | |||
| 13 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 14 | import { | ||
| 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 | */ | ||
| 26 | function 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 | |||
| 49 | function 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 | */ | ||
| 88 | export 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"; |
| 28 | import { useTheme } from "~/hooks/useTheme"; | 29 | import { useTheme } from "~/hooks/useTheme"; |
| 29 | import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; | 30 | import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; |
| 30 | import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; | 31 | import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; |
| 32 | import { canEditPullRequestComment } from "./pullRequestEditing.logic"; | ||
| 33 | import { orderDiffFiles } from "./pullRequestFileOrder.logic"; | ||
| 31 | import { | 34 | import { |
| 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 | } |
| 99 | 103 | ||
| 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 | */ | ||
| 109 | const 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. */ |
| 101 | const NO_SLICES: ReadonlyArray<DiffSlice> = []; | 116 | const NO_SLICES: ReadonlyArray<DiffSlice> = []; |
| 102 | 117 | ||
| ... | @@ -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.nextCursor | 277 | 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 late | 371 | // 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.comments | 476 | }:${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 | ); |
| 651 | 693 | ||
| 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 | <ReviewThreadCard | 767 | <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 | ); |
| 742 | 831 | ||
| 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 | <PendingReviewCommentCard | 837 | <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 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | 1 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { | 3 | import 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"; |
| 10 | import { | 12 | import { |
| 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"; |
| 34 | import { | 38 | import { |
| 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"; |
| 49 | import { cn } from "~/lib/utils"; | 54 | import { cn } from "~/lib/utils"; |
| 50 | import { readLocalApi } from "~/localApi"; | 55 | import { readLocalApi } from "~/localApi"; |
| 51 | import type { ReviewCommentContext } from "~/reviewCommentContext"; | 56 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 57 | import { useProjects } from "~/state/entities"; | ||
| 58 | import { useEnvironments } from "~/state/environments"; | ||
| 52 | import { useEnvironmentQuery } from "~/state/query"; | 59 | import { useEnvironmentQuery } from "~/state/query"; |
| 53 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 60 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 54 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 61 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | @@ -66,6 +73,7 @@ import { | ... | @@ -66,6 +73,7 @@ import { |
| 66 | } from "../ui/alert-dialog"; | 73 | } from "../ui/alert-dialog"; |
| 67 | import { Badge } from "../ui/badge"; | 74 | import { Badge } from "../ui/badge"; |
| 68 | import { Button } from "../ui/button"; | 75 | import { Button } from "../ui/button"; |
| 76 | import { Input } from "../ui/input"; | ||
| 69 | import { | 77 | import { |
| 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"; |
| 86 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 78 | import { toastManager } from "../ui/toast"; | 87 | import { toastManager } from "../ui/toast"; |
| 79 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; | 88 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; |
| 80 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; | 89 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 81 | import { DiffPanelLoadingState } from "../DiffPanelShell"; | 90 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| 82 | import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; | 91 | import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; |
| 83 | import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; | 92 | import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; |
| 93 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; | ||
| 84 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; | 94 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; |
| 85 | import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; | 95 | import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; |
| 86 | import { | 96 | import { |
| ... | @@ -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"; |
| 112 | import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; | ||
| 113 | import { | ||
| 114 | resolvePickableEnvironments, | ||
| 115 | type PickableEnvironment, | ||
| 116 | } from "./pullRequestProjectAssignment.logic"; | ||
| 117 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | ||
| 99 | import { | 118 | import { |
| 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 | }; |
| 116 | 142 | ||
| 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 | }; |
| 125 | 154 | ||
| 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 | }; |
| 136 | 175 | ||
| 137 | /** Named for the host rather than "externally": the point is where you will land. */ | 176 | /** |
| 138 | const 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", | 181 | const 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."; |
| 144 | 183 | ||
| 145 | const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ | 184 | const 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 told | 198 | * 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 | */ |
| 162 | const lastHandoffPromptByDraft = new Map<DraftId, string>(); | 201 | const lastHandoffPromptByDraft = new Map<string, string>(); |
| 202 | |||
| 203 | const 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 | */ | ||
| 212 | function 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. */ | ||
| 251 | const 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 | */ | ||
| 274 | function 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 | } | ||
| 163 | 343 | ||
| 164 | export function PullRequestDetailPanel({ | 344 | export 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 itself | 459 | // 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 | }); |
| 364 | 593 | ||
| 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 a | 612 | // 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 DevOps | 613 | // 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 | }; |
| 390 | 643 | ||
| 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 | }; |
| 395 | 675 | ||
| 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 chips | 718 | // 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 themselves | 719 | // 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 have | 720 | // 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 | }; |
| 440 | 725 | ||
| 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 the | 787 | // 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 toast | 788 | // 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 setup | 797 | // 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 no | 798 | // 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 | ? mergeMethod | 996 | ? 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 hidden | 1005 | // 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; | ||
| 728 | 1043 | ||
| 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 | <button | 1070 | <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 | Conflicts | 1121 | 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 it | 1228 | {/* 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 review | 1366 | 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 while | 1392 | 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 | <div | 1404 | <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 | <div | 1471 | <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 pinned | 1474 | // 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. The | 1475 | // 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 | <div | 1483 | <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 | <button | 1579 | <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({ |
| 1178 | 1642 | ||
| 1179 | {detail ? ( | 1643 | {detail ? ( |
| 1180 | <nav | 1644 | <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 height | 1763 | // 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 | <PullRequestTimelineTab | 1818 | <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 @@ |
| 1 | import type { ProjectId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 2 | import { CircleIcon } from "lucide-react"; | 2 | import { CircleIcon } from "lucide-react"; |
| 3 | import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; | 3 | import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | 4 | import { 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 | }); |
| 81 | 86 | ||
| 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(); |
| 92 | 131 | ||
| 93 | radioGroup?.props.onValueChange(projectId); | 132 | radioGroup?.props.onValueChange(`${environmentId} ${projectId}`); |
| 94 | expect(onProject).not.toHaveBeenCalled(); | 133 | expect(onProject).not.toHaveBeenCalled(); |
| 95 | 134 | ||
| 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"; |
| 8 | import { FolderGit2Icon, LayersIcon, ListFilterIcon, LoaderIcon, SearchIcon } from "lucide-react"; | 9 | import { |
| 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"; | ||
| 9 | import type { ElementType } from "react"; | 22 | import type { ElementType } from "react"; |
| 10 | 23 | ||
| 11 | import { cn } from "~/lib/utils"; | 24 | import { 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 icon | 100 | // 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({ |
| 101 | const ALL_PROJECTS_VALUE = "all"; | 114 | const 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. */ |
| 103 | const ALL_HOSTS_VALUE = ""; | 116 | const ALL_HOSTS_VALUE = ""; |
| 117 | /** The same trick for the servers, which are named by an id no empty string can collide with. */ | ||
| 118 | const ALL_SERVERS_VALUE = ""; | ||
| 119 | /** The unset value of each narrowing group, which no filter of theirs is named after. */ | ||
| 120 | const 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 | */ | ||
| 125 | const projectMenuValue = (project: { | ||
| 126 | readonly id: ProjectId; | ||
| 127 | readonly environmentId: EnvironmentId; | ||
| 128 | }) => `${project.environmentId} ${project.id}`; | ||
| 129 | |||
| 130 | const 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 | |||
| 136 | const 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 | |||
| 144 | const 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>>; | ||
| 104 | 149 | ||
| 105 | function PullRequestFilterRadioGroup<Value extends string>({ | 150 | function 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 real | 220 | * 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, where | 246 | * 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 list | 247 | * 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 | <MenuTrigger | 273 | <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 | <MenuRadioGroup | 349 | <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 | <MenuRadioItem | 387 | <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 | <ProjectFavicon | 397 | 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 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "~/lib/utils"; | ||
| 4 | |||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | import { Textarea } from "../ui/textarea"; | ||
| 7 | import { 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 | */ | ||
| 17 | export 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 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | PullRequestReaction, | ||
| 4 | PullRequestReactionContent, | ||
| 5 | PullRequestRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { SmilePlusIcon } from "lucide-react"; | ||
| 8 | import { useState } from "react"; | ||
| 9 | |||
| 10 | import { cn } from "~/lib/utils"; | ||
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 13 | |||
| 14 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 15 | import { toastManager } from "../ui/toast"; | ||
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 17 | import { | ||
| 18 | applyPendingPullRequestReactions, | ||
| 19 | PULL_REQUEST_REACTION_ORDER, | ||
| 20 | pullRequestReactionEmoji, | ||
| 21 | pullRequestReactionName, | ||
| 22 | pullRequestReactionTooltip, | ||
| 23 | } from "./pullRequestReactions.logic"; | ||
| 24 | |||
| 25 | const 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 | |||
| 28 | const 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. */ | ||
| 31 | function 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 | */ | ||
| 46 | export 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 for | 2 | * 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 | */ |
| 5 | import type { PullRequestReviewThread } from "@t3tools/contracts"; | 5 | import type { |
| 6 | EnvironmentId, | ||
| 7 | PullRequestRef, | ||
| 8 | PullRequestReviewThread, | ||
| 9 | PullRequestThreadComment, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 6 | import { | 11 | import { |
| 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"; |
| 13 | import { useRef, useState } from "react"; | 19 | import { useRef, useState } from "react"; |
| ... | @@ -20,6 +26,8 @@ import { Textarea } from "../ui/textarea"; | ... | @@ -20,6 +26,8 @@ import { Textarea } from "../ui/textarea"; |
| 20 | import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; | 26 | import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; |
| 21 | import { PullRequestActorLabel } from "./pullRequestPresentation"; | 27 | import { PullRequestActorLabel } from "./pullRequestPresentation"; |
| 22 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | 28 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 29 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; | ||
| 30 | import { PullRequestReactionBar } from "./PullRequestReactions"; | ||
| 23 | import type { PendingReviewComment } from "./pullRequestReviewStore"; | 31 | import type { PendingReviewComment } from "./pullRequestReviewStore"; |
| 24 | 32 | ||
| 25 | const CARD_CLASS = | 33 | const 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); |
| 109 | 135 | ||
| 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 | <PullRequestMarkdown | 216 | {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 @@ |
| 1 | import type { PullRequestListEntry } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { memo } from "react"; | 1 | import { memo } from "react"; |
| 4 | 2 | ||
| 5 | import { cn } from "~/lib/utils"; | 3 | import { cn } from "~/lib/utils"; |
| ... | @@ -7,6 +5,9 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation | ... | @@ -7,6 +5,9 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation |
| 7 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 5 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 8 | 6 | ||
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | ||
| 9 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | ||
| 10 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; | ||
| 10 | import { | 11 | import { |
| 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, a | 35 | * 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 description | 122 | matched in the description |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+359-29| ... | @@ -1,13 +1,22 @@ | ... | @@ -1,13 +1,22 @@ |
| 1 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | PullRequestActor, | ||
| 4 | PullRequestComment, | ||
| 5 | PullRequestDetailView, | ||
| 6 | PullRequestRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 2 | import { | 8 | import { |
| 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"; |
| 10 | import { useState, type ReactNode } from "react"; | 19 | import { useRef, useState, type ReactNode } from "react"; |
| 11 | 20 | ||
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | 21 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 13 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 22 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | @@ -21,9 +30,9 @@ import { Textarea } from "../ui/textarea"; | ... | @@ -21,9 +30,9 @@ import { Textarea } from "../ui/textarea"; |
| 21 | import { toastManager } from "../ui/toast"; | 30 | import { toastManager } from "../ui/toast"; |
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | import { | 32 | import { |
| 33 | PullRequestActorAvatar, | ||
| 24 | PullRequestActorLabel, | 34 | PullRequestActorLabel, |
| 25 | PullRequestCheckStatusIcon, | 35 | PullRequestCheckStatusIcon, |
| 26 | PullRequestMetaLine, | ||
| 27 | pullRequestCheckStatusLabel, | 36 | pullRequestCheckStatusLabel, |
| 28 | } from "./pullRequestPresentation"; | 37 | } from "./pullRequestPresentation"; |
| 29 | import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker"; | 38 | import { 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"; |
| 45 | import { | ||
| 46 | canEditPullRequestChangeRequest, | ||
| 47 | canEditPullRequestComment, | ||
| 48 | } from "./pullRequestEditing.logic"; | ||
| 36 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | 49 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 50 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; | ||
| 51 | import { PullRequestReactionBar } from "./PullRequestReactions"; | ||
| 37 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; | 52 | import { PullRequestConversationGhost } from "./PullRequestGhosts"; |
| 53 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; | ||
| 54 | |||
| 55 | /** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ | ||
| 56 | function 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. */ | ||
| 62 | function 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. */ | ||
| 77 | function 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. */ | ||
| 83 | interface 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 | */ | ||
| 96 | function 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. */ | ||
| 137 | function 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 | } | ||
| 38 | 188 | ||
| 39 | function MetaRow({ | 189 | function 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 heading | 258 | {/* 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 | }; |
| 213 | 397 | ||
| 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 | {activityPending | 529 | {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> |
| 275 | 539 | ||
| 276 | <Section title="Description"> | 540 | <Section title="Description"> |
| 277 | <PullRequestMarkdown | 541 | <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> |
| 282 | 584 | ||
| 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 | <div | 594 | <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 | <button | 600 | <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 | ? null | 720 | ? null |
| 391 | : thread === undefined | 721 | : thread === undefined |
| 392 | ? { kind: "comment", comment } | 722 | ? { kind: "comment", comment } |
| 393 | : thread.isResolved | 723 | : { kind: "thread", thread }; |
| 394 | ? null | ||
| 395 | : { kind: "thread", thread }; | ||
| 396 | return ( | 724 | return ( |
| 397 | <article | 725 | <article |
| 398 | key={comment.id} | 726 | key={comment.id} |
| 399 | // Offscreen comments skip style, layout and paint. Bot comments carry pages of | 727 | // 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 | <PullRequestActorLabel | 733 | <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 | <PullRequestMarkdown | 764 | <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 @@ |
| 1 | import type { PullRequestActor, PullRequestDetailView } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | PullRequestActor, | ||
| 4 | PullRequestComment, | ||
| 5 | PullRequestDetailView, | ||
| 6 | PullRequestRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 2 | import { | 8 | import { |
| 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"; |
| 12 | import { useState, type ReactNode } from "react"; | 19 | import { useState, type ReactNode } from "react"; |
| 13 | 20 | ||
| 14 | import { cn } from "~/lib/utils"; | 21 | import { cn } from "~/lib/utils"; |
| 15 | import { readLocalApi } from "~/localApi"; | 22 | import { readLocalApi } from "~/localApi"; |
| 23 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 16 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 25 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 17 | 26 | ||
| 18 | import { Button } from "../ui/button"; | 27 | import { Button } from "../ui/button"; |
| 19 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 28 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 29 | import { toastManager } from "../ui/toast"; | ||
| 20 | import { | 30 | import { |
| 21 | buildPullRequestTimeline, | 31 | buildPullRequestTimeline, |
| 22 | groupPullRequestTimelineConversations, | 32 | groupPullRequestTimelineConversations, |
| 23 | type PullRequestTimelineEvent, | 33 | type PullRequestTimelineEvent, |
| 24 | } from "./pullRequestDetail.logic"; | 34 | } from "./pullRequestDetail.logic"; |
| 35 | import { canEditPullRequestComment } from "./pullRequestEditing.logic"; | ||
| 25 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | 36 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 37 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; | ||
| 38 | import { PullRequestReactionBar } from "./PullRequestReactions"; | ||
| 26 | import { | 39 | import { |
| 27 | PullRequestActorAvatar, | 40 | PullRequestActorAvatar, |
| 28 | PullRequestDiffStat, | 41 | PullRequestDiffStat, |
| 29 | PullRequestMetaLine, | 42 | PullRequestMetaLine, |
| 30 | } from "./pullRequestPresentation"; | 43 | } from "./pullRequestPresentation"; |
| 31 | 44 | ||
| 45 | /** What every comment on the timeline needs to react; only the subject differs between them. */ | ||
| 46 | interface ReactionSurface { | ||
| 47 | readonly canReact: boolean; | ||
| 48 | readonly environmentId: EnvironmentId; | ||
| 49 | readonly reference: PullRequestRef; | ||
| 50 | readonly onRefresh: () => void; | ||
| 51 | } | ||
| 52 | |||
| 32 | function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) { | 53 | function 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 |
| 130 | 151 | ||
| 131 | function ConversationCard({ | 152 | function 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 |
| 180 | 264 | ||
| 181 | function ConversationGroup({ | 265 | function 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 }) { |
| 315 | 413 | ||
| 316 | export function PullRequestTimelineTab({ | 414 | export 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 | <ConversationGroup | 457 | <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 @@ | ||
| 1 | import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts"; | ||
| 2 | import { Children, isValidElement, type ReactNode } from "react"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | ||
| 6 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; | ||
| 7 | import { PullRequestRow } from "./PullRequestRow"; | ||
| 8 | import { pullRequestChecksState } from "./pullRequestPresentation"; | ||
| 9 | |||
| 10 | function check(status: PullRequestCheck["status"]): PullRequestCheck { | ||
| 11 | return { name: `check-${status}`, status, description: null, url: null }; | ||
| 12 | } | ||
| 13 | |||
| 14 | describe("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. */ | ||
| 29 | function 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 | |||
| 39 | function 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 | |||
| 64 | function 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 | |||
| 74 | describe("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 @@ |
| 1 | import type { | 1 | import { |
| 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"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 9 | ||
| ... | @@ -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 | }); |
| 59 | 64 | ||
| 65 | describe("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 | |||
| 60 | describe("ordering comments", () => { | 87 | describe("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 | |||
| 818 | describe("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 | |||
| 876 | describe("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 | |||
| 913 | describe("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 @@ |
| 1 | import type { | 1 | import 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"; |
| 9 | 14 | ||
| 10 | import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; | 15 | import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; |
| 11 | 16 | ||
| 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 | */ | ||
| 22 | export 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. */ | ||
| 42 | export 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. */ |
| 13 | export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { | 61 | export 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 | } |
| 45 | 95 | ||
| 46 | export type PullRequestTimelineRow = | 96 | export 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.mergedAt | 198 | ...(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 | */ | ||
| 716 | export 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 | */ | ||
| 751 | const 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 | |||
| 762 | export 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 @@ | ||
| 1 | import type { | ||
| 2 | PullRequestActor, | ||
| 3 | PullRequestCapabilities, | ||
| 4 | PullRequestComment, | ||
| 5 | PullRequestDetail, | ||
| 6 | PullRequestViewerPermissions, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | canEditPullRequestChangeRequest, | ||
| 12 | canEditPullRequestComment, | ||
| 13 | } from "./pullRequestEditing.logic"; | ||
| 14 | |||
| 15 | type Subject = Pick<PullRequestDetail, "author" | "capabilities" | "viewer" | "viewerPermissions">; | ||
| 16 | |||
| 17 | function actor(login: string): PullRequestActor { | ||
| 18 | return { login, name: null, avatarUrl: null }; | ||
| 19 | } | ||
| 20 | |||
| 21 | function 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 | |||
| 35 | function 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 | |||
| 48 | function 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 | |||
| 58 | function 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 | |||
| 72 | describe("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 | |||
| 125 | describe("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 @@ | ||
| 1 | import 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. */ | ||
| 4 | type 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. */ | ||
| 10 | function 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 | */ | ||
| 21 | export 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 | */ | ||
| 34 | export 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 @@ | ||
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic"; | ||
| 5 | |||
| 6 | /** Only the path and the patch's own lines matter here; the viewer fills the rest in. */ | ||
| 7 | function file(name: string, additionLines: ReadonlyArray<string> = []): FileDiffMetadata { | ||
| 8 | return { name, hunks: [], additionLines, deletionLines: [] } as unknown as FileDiffMetadata; | ||
| 9 | } | ||
| 10 | |||
| 11 | function order(files: ReadonlyArray<FileDiffMetadata>): Array<string> { | ||
| 12 | return orderDiffFiles(files).map((entry) => entry.name); | ||
| 13 | } | ||
| 14 | |||
| 15 | describe("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 | |||
| 42 | describe("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 @@ | ||
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 2 | |||
| 3 | import { 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 | */ | ||
| 12 | export type DiffFileTier = "source" | "test" | "generated"; | ||
| 13 | |||
| 14 | const 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 | ]); | ||
| 24 | const GENERATED_DIRECTORIES = new Set([ | ||
| 25 | "__snapshots__", | ||
| 26 | "__generated__", | ||
| 27 | "dist", | ||
| 28 | "build", | ||
| 29 | "vendor", | ||
| 30 | ]); | ||
| 31 | const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); | ||
| 32 | const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; | ||
| 33 | |||
| 34 | export 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 | |||
| 56 | function 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 | |||
| 62 | function 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. */ | ||
| 67 | function 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. | ||
| 79 | const IMPORT_SPECIFIER = /(?:\bfrom|\bimport|\brequire\s*\()\s*\(?\s*["']([^"']+)["']/g; | ||
| 80 | |||
| 81 | /** Which changed files a file's patch lines import, by path. */ | ||
| 82 | function 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 | */ | ||
| 121 | function 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`. */ | ||
| 141 | function 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 | */ | ||
| 149 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | ||
| 4 | import { readLocalApi } from "~/localApi"; | ||
| 5 | |||
| 6 | import { toastManager } from "../ui/toast"; | ||
| 7 | |||
| 8 | export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; | ||
| 9 | |||
| 10 | /** Named for the host rather than "externally": the point is where you will land. */ | ||
| 11 | export 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 | |||
| 18 | export 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. */ | ||
| 22 | export 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 | */ | ||
| 40 | export 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 @@ |
| 1 | import type { PullRequestListEntry } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, PullRequestListEntry } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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"; |
| 18 | 27 | ||
| 19 | const VIEWERS = { "github.com": "Bilal" } as const; | 28 | const VIEWERS = { "github.com": "Bilal" } as const; |
| 20 | const NO_VIEWERS = {} as const; | 29 | const NO_VIEWERS = {} as const; |
| 21 | 30 | ||
| 22 | function entry(overrides: Partial<PullRequestListEntry> & Pick<PullRequestListEntry, "number">) { | 31 | function 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 | } |
| 46 | 58 | ||
| 47 | describe("pull request involvement filtering", () => { | 59 | describe("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 | }); |
| 210 | 222 | ||
| 223 | describe("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 | |||
| 310 | describe("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 | |||
| 211 | describe("resolveProjectScope", () => { | 389 | describe("resolveProjectScope", () => { |
| 212 | const projects = [{ id: "p1" }, { id: "p2" }]; | 390 | const projects = [{ id: "p1" }, { id: "p2" }]; |
| 213 | 391 | ||
| ... | @@ -283,7 +461,7 @@ describe("ranking what a search found", () => { | ... | @@ -283,7 +461,7 @@ describe("ranking what a search found", () => { |
| 283 | }); | 461 | }); |
| 284 | 462 | ||
| 285 | describe("line counts that arrive after the rows", () => { | 463 | describe("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 }]]); |
| 287 | 465 | ||
| 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", () => { |
| 304 | describe("merging line counts across keyed stats queries", () => { | 482 | describe("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 | }); |
| 315 | 493 | ||
| 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 | }); |
| 327 | 505 | ||
| 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 | }); |
| 336 | 514 | ||
| ... | @@ -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 | |||
| 665 | const ENV_1 = "env-1" as EnvironmentId; | ||
| 666 | const ENV_2 = "env-2" as EnvironmentId; | ||
| 667 | |||
| 668 | describe("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 | |||
| 763 | describe('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 | |||
| 819 | describe("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 | |||
| 841 | describe("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 | |||
| 900 | describe("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 | |||
| 925 | describe("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 | |||
| 978 | describe("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 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | ||
| 3 | import { PullRequestListEntry, PullRequestListResult } from "@t3tools/contracts"; | 3 | import { |
| 4 | import type { PullRequestInvolvement, PullRequestListState } from "@t3tools/contracts"; | 4 | EnvironmentId, |
| 5 | PullRequestListEntry, | ||
| 6 | PullRequestListResult, | ||
| 7 | resolvePullRequestAuthorFilter, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import 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 | */ | ||
| 22 | export interface EnvironmentPullRequestEntry extends PullRequestListEntry { | ||
| 23 | readonly environmentId: EnvironmentId; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface EnvironmentPullRequestStat extends PullRequestDiffStat { | ||
| 27 | readonly environmentId: EnvironmentId; | ||
| 28 | } | ||
| 5 | 29 | ||
| 6 | export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; | 30 | export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; |
| 7 | 31 | ||
| 8 | export interface PullRequestGroup { | 32 | export 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 | } |
| 13 | 37 | ||
| 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 | */ | ||
| 15 | export type PullRequestViewers = PullRequestListResult["viewers"]; | 44 | export type PullRequestViewers = PullRequestListResult["viewers"]; |
| 16 | 45 | ||
| 46 | /** A row plus the environment that read it, where the caller has one to give. */ | ||
| 47 | type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; | ||
| 48 | |||
| 49 | export const pullRequestViewerKey = (entry: ScopedEntry): string => | ||
| 50 | `${entry.environmentId ?? ""} ${entry.host}`; | ||
| 51 | |||
| 17 | const GROUP_LABELS: Record<PullRequestGroupKey, string> = { | 52 | const 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 | } |
| 27 | 62 | ||
| 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 | */ | ||
| 67 | export 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 from | 77 | * 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 nothing | 78 | * 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 | */ |
| 33 | function isAuthoredByViewer(entry: PullRequestListEntry, viewers: PullRequestViewers): boolean { | 81 | function 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 | } |
| 37 | 85 | ||
| 86 | /** What `review:` and `status:` take, in GitHub's spelling and in the contract's. */ | ||
| 87 | const 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 | }; | ||
| 95 | const 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 | */ | ||
| 107 | const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g; | ||
| 108 | /** The contract's own ceilings on a qualifier: this many names, each this long. */ | ||
| 109 | const MAX_QUALIFIER_VALUES = 10; | ||
| 110 | const MAX_QUALIFIER_LENGTH = 200; | ||
| 111 | |||
| 112 | function 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. */ | ||
| 117 | function 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 | */ | ||
| 134 | function 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 | */ | ||
| 155 | export 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`. */ |
| 39 | export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean { | 239 | export 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 Reviewing | 248 | * 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 | */ |
| 51 | export function filterPullRequestsByInvolvement( | 251 | export 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 the | 275 | * 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 | */ |
| 78 | export function narrowPullRequestsToFilters( | 278 | export 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 | } |
| 93 | 293 | ||
| 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 | */ | ||
| 304 | export 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 is | 326 | * 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 | */ |
| 98 | export function groupPullRequestsByInvolvement( | 329 | export 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 | } |
| 120 | 351 | ||
| 121 | /** Repository plus number is unique on one host, so the host makes the key unique overall. */ | 352 | /** |
| 122 | export 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 | */ | ||
| 357 | export function pullRequestEntryKey(entry: ScopedEntry): string { | ||
| 358 | const scope = entry.environmentId === undefined ? "" : `${entry.environmentId}:`; | ||
| 359 | return `${scope}${entry.host}:${entry.repository}#${entry.number}`; | ||
| 124 | } | 360 | } |
| 125 | 361 | ||
| 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 only | 367 | * 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 | */ |
| 134 | export function partitionPullRequestsWithPriority( | 370 | export 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< |
| 183 | export function mergePullRequestDiffStats( | 418 | export 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 | } |
| 202 | 435 | ||
| 436 | /** A project id only names a project within its own environment, so the key carries both. */ | ||
| 437 | const 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 | */ | ||
| 450 | export 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 | */ | ||
| 471 | export 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. */ |
| 204 | const SNAPSHOT_MAX_ENTRIES = 99; | 520 | const SNAPSHOT_MAX_ENTRIES = 99; |
| 205 | 521 | ||
| 206 | type SnapshotStorage = Pick<Storage, "getItem" | "setItem">; | 522 | type SnapshotStorage = Pick<Storage, "getItem" | "setItem">; |
| 207 | 523 | ||
| 208 | const 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 | */ | ||
| 529 | export const pullRequestEnvironmentSetKey = (environmentIds: ReadonlyArray<string>): string => | ||
| 530 | [...environmentIds].sort((left, right) => left.localeCompare(right)).join(","); | ||
| 531 | |||
| 532 | const snapshotStorageKey = (environmentSetKey: string) => | ||
| 533 | `t3.pullRequests.list:${environmentSetKey}`; | ||
| 209 | 534 | ||
| 210 | /** | 535 | /** |
| 211 | * The priority groups' own server-filtered answers, carried with the feed. An authored pull | 536 | * 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 | */ |
| 215 | export interface PullRequestPartitionsSnapshot { | 540 | export interface PullRequestPartitionsSnapshot { |
| 216 | readonly authored: ReadonlyArray<PullRequestListEntry>; | 541 | readonly authored: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 217 | readonly reviewing: ReadonlyArray<PullRequestListEntry>; | 542 | readonly reviewing: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 218 | } | 543 | } |
| 219 | 544 | ||
| 220 | export interface PullRequestListSnapshot { | 545 | export 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 | } |
| 225 | 550 | ||
| ... | @@ -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 a | 554 | * 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 | */ |
| 557 | const EnvironmentPullRequestEntrySchema = Schema.Struct({ | ||
| 558 | ...PullRequestListEntry.fields, | ||
| 559 | environmentId: EnvironmentId, | ||
| 560 | }); | ||
| 561 | |||
| 232 | const decodeSnapshot = Schema.decodeUnknownOption( | 562 | const 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 | */ |
| 253 | export function readPullRequestListSnapshot( | 589 | export 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( |
| 266 | 602 | ||
| 267 | export function writePullRequestListSnapshot( | 603 | export 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 === undefined | 623 | ...(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 | } |
| 318 | 656 | ||
| 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 | */ | ||
| 663 | export 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 | */ | ||
| 686 | export 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 | */ | ||
| 715 | export 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 recency | 758 | * 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 | */ |
| 356 | export function rankPullRequestMatches( | 761 | export 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 row | 774 | * 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 | */ |
| 372 | export function withDiffStat( | 777 | export 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"; |
| 8 | import { | 9 | import { |
| 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 | } |
| 146 | 148 | ||
| 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 | */ | ||
| 153 | const 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 | |||
| 174 | export 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 | */ | ||
| 184 | export 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 | |||
| 147 | export function PullRequestActorAvatar({ | 194 | export 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 @@ | ||
| 1 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | assignProjectsToEnvironments, | ||
| 6 | resolvePickableEnvironments, | ||
| 7 | type AssignableProject, | ||
| 8 | } from "./pullRequestProjectAssignment.logic"; | ||
| 9 | |||
| 10 | const 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 | |||
| 21 | const envs = (...ids: ReadonlyArray<string>) => ids as ReadonlyArray<EnvironmentId>; | ||
| 22 | |||
| 23 | const plain = (assignment: Map<EnvironmentId, ProjectId[]>) => | ||
| 24 | Object.fromEntries([...assignment].map(([id, projectIds]) => [id, projectIds])); | ||
| 25 | |||
| 26 | describe("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 | |||
| 139 | const connected = (...ids: ReadonlyArray<string>) => | ||
| 140 | ids.map((id) => ({ environmentId: id as EnvironmentId, label: `Server ${id}` })); | ||
| 141 | |||
| 142 | const on = (environmentId: string, projectId: string) => ({ | ||
| 143 | environmentId: environmentId as EnvironmentId, | ||
| 144 | projectId: projectId as ProjectId, | ||
| 145 | }); | ||
| 146 | |||
| 147 | describe("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 @@ | ||
| 1 | import 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. */ | ||
| 4 | export 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 | */ | ||
| 17 | function 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 | */ | ||
| 28 | export 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. */ | ||
| 67 | export 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 | */ | ||
| 85 | export 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 @@ | ||
| 1 | import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | applyPendingPullRequestReactions, | ||
| 6 | PULL_REQUEST_REACTION_ORDER, | ||
| 7 | pullRequestReactionEmoji, | ||
| 8 | pullRequestReactionName, | ||
| 9 | pullRequestReactionTooltip, | ||
| 10 | } from "./pullRequestReactions.logic"; | ||
| 11 | |||
| 12 | function reaction(overrides: Partial<PullRequestReaction> = {}): PullRequestReaction { | ||
| 13 | return { | ||
| 14 | content: "heart", | ||
| 15 | count: 1, | ||
| 16 | actors: ["octocat"], | ||
| 17 | viewerHasReacted: false, | ||
| 18 | ...overrides, | ||
| 19 | }; | ||
| 20 | } | ||
| 21 | |||
| 22 | describe("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 | |||
| 49 | describe("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 | |||
| 140 | describe("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 | |||
| 161 | describe("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 @@ | ||
| 1 | import 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. */ | ||
| 4 | export 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 | |||
| 15 | const 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. */ | ||
| 27 | const 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 | |||
| 38 | export function pullRequestReactionEmoji(content: PullRequestReactionContent): string { | ||
| 39 | return REACTION_EMOJI[content]; | ||
| 40 | } | ||
| 41 | |||
| 42 | export 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. */ | ||
| 47 | const NAMED_ACTOR_LIMIT = 3; | ||
| 48 | |||
| 49 | function 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. */ | ||
| 56 | function 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 | */ | ||
| 73 | export 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 | */ | ||
| 88 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | const 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 | */ | ||
| 9 | export 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"; |
| 9 | 9 | ||
| 10 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; | 10 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 11 | import { cn } from "../../lib/utils"; | 11 | import { cn } from "../../lib/utils"; |
| 12 | import { usePrimaryEnvironment } from "../../state/environments"; | 12 | import { useEnvironments } from "../../state/environments"; |
| 13 | import { | 13 | import { |
| 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 @@ |
| 1 | import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { type MouseEvent, useCallback } from "react"; | 4 | import { 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 !== true | 203 | 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 another | 205 | // 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 same | 206 | // 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"; |
| 14 | 15 | ||
| ... | @@ -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 | }); |
| 421 | 422 | ||
| 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 => ({ |
| 161 | export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>; | 167 | export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>; |
| 162 | 168 | ||
| 163 | export function pullRequestSurfaceId(target: { | 169 | export 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 | } |
| 170 | 181 | ||
| 171 | export function pullRequestSurface(target: { | 182 | export 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 | } |
| 184 | 197 | ||
| 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 | */ | ||
| 204 | export 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 | |||
| 185 | const upsertSurface = ( | 215 | const 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"; |
| 13 | import { | 15 | import { |
| 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 |
| 26 | 30 | ||
| 27 | import { | 31 | import { |
| 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"; |
| 57 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; | ||
| 44 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; | 58 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 45 | import { | 59 | import { |
| 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 "../ |
| 65 | import { SidebarInset } from "../components/ui/sidebar"; | 79 | import { SidebarInset } from "../components/ui/sidebar"; |
| 66 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; | 80 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 67 | import { | 81 | import { |
| 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"; |
| 75 | import { useDebouncedValue } from "../state/queries"; | 89 | import { useDebouncedValue } from "../state/queries"; |
| 76 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; | 90 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; |
| 77 | import { usePrimaryEnvironment } from "../state/environments"; | 91 | import { useEnvironments } from "../state/environments"; |
| 78 | import { pullRequestEnvironment } from "../state/pullRequests"; | 92 | import { |
| 79 | import { useEnvironmentQuery } from "../state/query"; | 93 | pullRequestEnvironment, |
| 94 | usePullRequestList, | ||
| 95 | usePullRequestListStats, | ||
| 96 | type EnvironmentQueryTarget, | ||
| 97 | } from "../state/pullRequests"; | ||
| 80 | import { useAtomCommand } from "../state/use-atom-command"; | 98 | import { useAtomCommand } from "../state/use-atom-command"; |
| 81 | import { cn } from "~/lib/utils"; | 99 | import { cn } from "~/lib/utils"; |
| 82 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; | 100 | import { 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"; |
| 85 | export interface PullRequestsSearch { | 103 | export 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 | } |
| 100 | 136 | ||
| 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; |
| 128 | const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; | 164 | const 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. */ |
| 130 | const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel"); | 166 | const 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 | */ | ||
| 173 | const 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. */ | ||
| 175 | const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; | ||
| 131 | const EMPTY_PREVIEW_SESSIONS = {}; | 176 | const EMPTY_PREVIEW_SESSIONS = {}; |
| 132 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); | 177 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); |
| 133 | const EMPTY_PENDING_SURFACES = new Set<string>(); | 178 | const 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.projectId | 192 | ...(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.selectedProjectId | 199 | ...(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")({ |
| 159 | function PullRequestsRouteView() { | 218 | function 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 either | 226 | () => |
| 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 is | 265 | // 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 is | 266 | // "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 listed | 269 | // 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 | projects | 277 | 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.isDraft | 392 | const activePullRequestSurfaceId = activePullRequestSurface?.id; |
| 217 | ? current | 393 | 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 | ); | ||
| 221 | 401 | ||
| 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]); | ||
| 270 | 462 | ||
| 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. Sending | 548 | // 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 a | 549 | // 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 : []; | ||
| 284 | 567 | ||
| 285 | // Typing a search, or clearing one, starts the list again at its first page. Without this the | 568 | // 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 with | 569 | // 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]); |
| 291 | 574 | ||
| 292 | const listQuery = useEnvironmentQuery( | 575 | /** The listing input each environment is asked for, which differs only in its continuation. */ |
| 293 | pullRequestEnvironmentId === null | 576 | const listTargets = useMemo( |
| 294 | ? null | 577 | () => |
| 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 page | 583 | // 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); | ||
| 311 | 622 | ||
| 312 | /** | 623 | /** |
| 313 | * The same filters with nothing typed, read whether or not anything is. It is the same atom the | 624 | * 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 has | 630 | * 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 === null | 634 | () => |
| 324 | ? null | 635 | 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 authored | 658 | // 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 these | 659 | // 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 can | 660 | // 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 tabs | 662 | // 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 || !partitionsWanted | 666 | // input uses: the atoms are keyed by their input, so the Authored tab then reads this answer. |
| 345 | ? null | 667 | 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 || !partitionsWanted | 680 | } satisfies PullRequestListInput, |
| 359 | ? null | 681 | })); |
| 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 and | 694 | // 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, narrowed | 727 | // 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 would | 746 | // 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 for | 747 | // 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 render | 748 | // 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 read | 749 | // 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 which | 754 | // 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 has | 772 | // 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 previous | 773 | // 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 !== null | 784 | 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 === scopeKey | 788 | current.scope === scopeKey |
| 455 | ? current.partitions | 789 | ? current.partitions |
| 456 | : undefined; | 790 | : undefined; |
| 457 | // A search's answer is the search's, not the workspace's, so only unsearched lists | 791 | // 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 settling | 792 | // 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 holding | 838 | // 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 rows | 839 | // 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 a | 840 | // 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 are | 841 | // 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 a | 842 | // 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 is | 855 | // 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 the | 856 | // 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 from | 857 | // 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; |
| 526 | 879 | ||
| 527 | // A longer page is the same list with more on the end, so the rows already read stay where | 880 | // `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 anything | 881 | // 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 rows | 892 | // 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 "the | 906 | // 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 page | 907 | // 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]); |
| 562 | 912 | ||
| 563 | // Carrying on where the last answer stopped, and only raising the page size for the hosts that | 913 | // 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 the | 916 | // 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 | }; |
| 581 | 942 | ||
| ... | @@ -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 | ); |
| 616 | 978 | ||
| 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 that | 998 | // 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 arrive | 999 | // 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 | ]); |
| 657 | 1036 | ||
| ... | @@ -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 === scopeKey | 1099 | loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey |
| 721 | ? loaded.partitions | 1100 | ? 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 = partitionsWanted | 1103 | // 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 | ]); |
| 742 | 1134 | ||
| 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: groups | 1139 | 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 === 0 | 1155 | environmentId, |
| 759 | ? null | 1156 | 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 counts | 1160 | // 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 until | 1161 | // 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]); |
| 774 | 1169 | ||
| 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 && selectedProjectId | 1172 | 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 !== null | 1188 | 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 | ); |
| 838 | 1210 | ||
| ... | @@ -884,13 +1256,15 @@ function PullRequestsRouteView() { | ... | @@ -884,13 +1256,15 @@ function PullRequestsRouteView() { |
| 884 | 1256 | ||
| 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 | <PullRequestsUnavailableState | 1311 | <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 | showProjectTitle | 1355 | 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 nothing | 1361 | // 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_SCORE | 1365 | scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE |
| 988 | } | 1366 | } |
| 989 | selected={ | 1367 | selected={ |
| 990 | selected?.repository === entry.repository && selected.number === entry.number | 1368 | 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 | <PullRequestFiltersMenu | 1431 | <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} /> |
| 1116 | 1520 | ||
| 1117 | {rightPanelState.isOpen && activePullRequestSurface && pullRequestEnvironmentId !== null ? ( | 1521 | {rightPanelState.isOpen && activePullRequestSurface && panelEnvironmentId !== null ? ( |
| 1118 | <RightPanelTabs | 1522 | <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 | <PullRequestDetailPanel | 1563 | <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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests"; | 2 | import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests"; |
| 3 | import type { | ||
| 4 | EnvironmentId, | ||
| 5 | PullRequestListInput, | ||
| 6 | PullRequestListStatsInput, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 10 | import { useCallback, useMemo } from "react"; | ||
| 2 | 11 | ||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | 12 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 14 | import { | ||
| 15 | mergePullRequestLists, | ||
| 16 | type EnvironmentPullRequestStat, | ||
| 17 | type MergedPullRequestList, | ||
| 18 | } from "../components/pullRequest/pullRequestList.logic"; | ||
| 19 | import { formatEnvironmentQueryError } from "./query"; | ||
| 4 | 20 | ||
| 5 | export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); | 21 | export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); |
| 22 | |||
| 23 | export interface EnvironmentQueryTarget<Input> { | ||
| 24 | readonly environmentId: EnvironmentId; | ||
| 25 | readonly input: Input; | ||
| 26 | } | ||
| 27 | |||
| 28 | interface 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 | */ | ||
| 44 | function 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 | |||
| 85 | const usePullRequestListsQuery = createMergedEnvironmentQuery( | ||
| 86 | "web-pull-requests:list", | ||
| 87 | pullRequestEnvironment.list, | ||
| 88 | ); | ||
| 89 | |||
| 90 | const usePullRequestStatsQuery = createMergedEnvironmentQuery( | ||
| 91 | "web-pull-requests:list-stats", | ||
| 92 | pullRequestEnvironment.listStats, | ||
| 93 | ); | ||
| 94 | |||
| 95 | export 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. */ | ||
| 103 | export 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. */ | ||
| 112 | export 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 | } |
| 16 | 16 | ||
| 17 | function formatError(cause: Cause.Cause<unknown>): string { | 17 | export 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 > 0 | 19 | return error instanceof Error && error.message.trim().length > 0 |
| 20 | ? error.message | 20 | ? 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 click | 44 | - Open the review directly in your browser with one click |
| 45 | - Check out a teammate's branch to review code locally | 45 | - Check out a teammate's branch to review code locally |
| 46 | 46 | ||
| 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 Glance | 55 | ### Know Your Setup at a Glance |
| 48 | 56 | ||
| 49 | The **Source Control settings** page shows you exactly what's connected: | 57 | The **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 separate | 165 | * 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 requests | 166 | * 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | EnvironmentAuthInvalidError, | ||
| 5 | EnvironmentInternalError, | ||
| 6 | EnvironmentOperationForbiddenError, | ||
| 7 | EnvironmentRequestInvalidError, | ||
| 8 | EnvironmentResourceNotFoundError, | ||
| 9 | EnvironmentScopeRequiredError, | ||
| 10 | } from "./environmentHttp.ts"; | ||
| 11 | |||
| 12 | const traceId = "trace-1"; | ||
| 13 | |||
| 14 | describe("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 | } |
| 110 | 114 | ||
| 111 | export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()( | 115 | export 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 | } |
| 124 | 132 | ||
| 125 | export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()( | 133 | export 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 | } |
| 138 | 150 | ||
| 139 | export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()( | 151 | export 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 | } |
| 152 | 168 | ||
| 153 | export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()( | 169 | export 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 | } |
| 166 | 186 | ||
| 167 | export const EnvironmentResourceNotFoundReason = Schema.Literals(["thread_not_found"]); | 187 | export 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 | } |
| 183 | 207 | ||
| 184 | export const EnvironmentHttpCommonError = Schema.Union([ | 208 | export 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"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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"; |
| 9 | 12 | ||
| 10 | const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult); | 13 | const 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 | |||
| 160 | describe("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 | |||
| 184 | describe("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 | |||
| 199 | describe("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 | |||
| 216 | describe("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; |
| 25 | export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]); | 25 | export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]); |
| 26 | export type PullRequestListState = typeof PullRequestListState.Type; | 26 | export type PullRequestListState = typeof PullRequestListState.Type; |
| 27 | 27 | ||
| 28 | /** Where a review stands overall, as a host that summarises its reviews reports it. */ | ||
| 29 | export const PullRequestReviewDecision = Schema.Literals([ | ||
| 30 | "approved", | ||
| 31 | "changes-requested", | ||
| 32 | "review-required", | ||
| 33 | ]); | ||
| 34 | export type PullRequestReviewDecision = typeof PullRequestReviewDecision.Type; | ||
| 35 | |||
| 36 | /** One qualifier's value, bounded because it is written into a host's own search query. */ | ||
| 37 | const PullRequestQualifierValue = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); | ||
| 38 | const 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 | */ | ||
| 50 | export 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 | }); | ||
| 67 | export type PullRequestListFilters = typeof PullRequestListFilters.Type; | ||
| 68 | |||
| 69 | /** The one-glyph summary of a change request's checks, as its list row wears it. */ | ||
| 70 | export const PullRequestChecksState = Schema.Literals(["passing", "failing", "pending"]); | ||
| 71 | export type PullRequestChecksState = typeof PullRequestChecksState.Type; | ||
| 72 | |||
| 28 | export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]); | 73 | export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]); |
| 29 | export type PullRequestMergeability = typeof PullRequestMergeability.Type; | 74 | export type PullRequestMergeability = typeof PullRequestMergeability.Type; |
| 30 | 75 | ||
| 31 | export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]); | 76 | export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]); |
| 32 | export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type; | 77 | export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type; |
| 33 | 78 | ||
| 34 | export const PullRequestAction = Schema.Literals(["merge", "ready", "draft", "close", "reopen"]); | 79 | export 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 | ]); | ||
| 35 | export type PullRequestAction = typeof PullRequestAction.Type; | 97 | export type PullRequestAction = typeof PullRequestAction.Type; |
| 36 | 98 | ||
| 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 | */ | ||
| 104 | export const PullRequestUpdateMethod = Schema.Literals(["merge", "rebase"]); | ||
| 105 | export 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 | */ | ||
| 115 | export const PullRequestBaseComparison = Schema.Literals(["up-to-date", "behind", "unknown"]); | ||
| 116 | export type PullRequestBaseComparison = typeof PullRequestBaseComparison.Type; | ||
| 117 | |||
| 37 | export const PullRequestActor = Schema.Struct({ | 118 | export 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 | }); |
| 67 | export type PullRequestCheck = typeof PullRequestCheck.Type; | 148 | export type PullRequestCheck = typeof PullRequestCheck.Type; |
| 68 | 149 | ||
| 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 | */ | ||
| 155 | export const PullRequestReactionContent = Schema.Literals([ | ||
| 156 | "thumbs-up", | ||
| 157 | "thumbs-down", | ||
| 158 | "laugh", | ||
| 159 | "hooray", | ||
| 160 | "confused", | ||
| 161 | "heart", | ||
| 162 | "rocket", | ||
| 163 | "eyes", | ||
| 164 | ]); | ||
| 165 | export type PullRequestReactionContent = typeof PullRequestReactionContent.Type; | ||
| 166 | |||
| 167 | /** One reaction and everyone behind it, which is what the hover on a reaction pill says. */ | ||
| 168 | export 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 | }); | ||
| 181 | export type PullRequestReaction = typeof PullRequestReaction.Type; | ||
| 182 | |||
| 69 | export const PullRequestCommentKind = Schema.Literals([ | 183 | export 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 | }); |
| 86 | export type PullRequestComment = typeof PullRequestComment.Type; | 202 | export type PullRequestComment = typeof PullRequestComment.Type; |
| 87 | 203 | ||
| ... | @@ -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 | }); |
| 107 | export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; | 224 | export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; |
| 108 | 225 | ||
| ... | @@ -197,6 +314,20 @@ export const PullRequestReviewCapabilities = Schema.Struct({ | ... | @@ -197,6 +314,20 @@ export const PullRequestReviewCapabilities = Schema.Struct({ |
| 197 | }); | 314 | }); |
| 198 | export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type; | 315 | export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type; |
| 199 | 316 | ||
| 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 | */ | ||
| 323 | export 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 | }); | ||
| 329 | export 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 without | 332 | * 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 whoever | 370 | * 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 rather | 371 | * 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 | }); |
| 242 | export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; | 392 | export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; |
| 243 | 393 | ||
| ... | @@ -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 | }); |
| 266 | export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; | 421 | export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; |
| 267 | 422 | ||
| ... | @@ -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 | }); |
| 307 | export type PullRequestListEntry = typeof PullRequestListEntry.Type; | 466 | export type PullRequestListEntry = typeof PullRequestListEntry.Type; |
| 308 | 467 | ||
| ... | @@ -324,7 +483,14 @@ export type PullRequestListCursors = typeof PullRequestListCursors.Type; | ... | @@ -324,7 +483,14 @@ export type PullRequestListCursors = typeof PullRequestListCursors.Type; |
| 324 | export const PullRequestListInput = Schema.Struct({ | 483 | export 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 them | 496 | * 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 | }); |
| 503 | export type PullRequestDetail = typeof PullRequestDetail.Type; | 690 | export type PullRequestDetail = typeof PullRequestDetail.Type; |
| 504 | 691 | ||
| ... | @@ -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 | }); |
| 531 | export type PullRequestActivity = typeof PullRequestActivity.Type; | 723 | export type PullRequestActivity = typeof PullRequestActivity.Type; |
| 532 | 724 | ||
| ... | @@ -562,6 +754,14 @@ export const PullRequestDiffInput = Schema.Struct({ | ... | @@ -562,6 +754,14 @@ export const PullRequestDiffInput = Schema.Struct({ |
| 562 | }); | 754 | }); |
| 563 | export type PullRequestDiffInput = typeof PullRequestDiffInput.Type; | 755 | export type PullRequestDiffInput = typeof PullRequestDiffInput.Type; |
| 564 | 756 | ||
| 757 | /** Real line counts for a file whose hunks the host withheld from the patch. */ | ||
| 758 | export const PullRequestOmittedFileStat = Schema.Struct({ | ||
| 759 | path: TrimmedNonEmptyString, | ||
| 760 | additions: Schema.Number, | ||
| 761 | deletions: Schema.Number, | ||
| 762 | }); | ||
| 763 | export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type; | ||
| 764 | |||
| 565 | export const PullRequestDiffResult = Schema.Struct({ | 765 | export 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 | }); |
| 575 | export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; | 780 | export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; |
| 576 | 781 | ||
| ... | @@ -594,7 +799,14 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten | ... | @@ -594,7 +799,14 @@ export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileConten |
| 594 | export const PullRequestActionInput = Schema.Struct({ | 799 | export 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 | }); |
| 599 | export type PullRequestActionInput = typeof PullRequestActionInput.Type; | 811 | export type PullRequestActionInput = typeof PullRequestActionInput.Type; |
| 600 | 812 | ||
| ... | @@ -610,6 +822,40 @@ export const PullRequestCommentInput = Schema.Struct({ | ... | @@ -610,6 +822,40 @@ export const PullRequestCommentInput = Schema.Struct({ |
| 610 | }); | 822 | }); |
| 611 | export type PullRequestCommentInput = typeof PullRequestCommentInput.Type; | 823 | export type PullRequestCommentInput = typeof PullRequestCommentInput.Type; |
| 612 | 824 | ||
| 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 | */ | ||
| 835 | export 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 | }); | ||
| 840 | export 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 | */ | ||
| 851 | export const PullRequestCommentUpdateInput = Schema.Struct({ | ||
| 852 | ...PullRequestRef.fields, | ||
| 853 | commentId: TrimmedNonEmptyString, | ||
| 854 | kind: Schema.Literals(["issue-comment", "review-comment"]), | ||
| 855 | body: CommentBody, | ||
| 856 | }); | ||
| 857 | export 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. */ |
| 614 | export const PullRequestReviewCommentDraft = Schema.Struct({ | 860 | export 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 | }); |
| 654 | export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type; | 900 | export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type; |
| 655 | 901 | ||
| 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 | */ | ||
| 906 | export 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 | }); | ||
| 917 | export type PullRequestReactionInput = typeof PullRequestReactionInput.Type; | ||
| 918 | |||
| 656 | /** | 919 | /** |
| 657 | * Asking for a review and taking the request back are one operation with `requested` turned | 920 | * 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 is | 921 | * 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 | } |
| 727 | 990 | ||
| 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 | */ | ||
| 1002 | export 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 reported | 1012 | * 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 when | 1013 | * 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"; |
| 91 | import { | 94 | import { |
| 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 | }); |
| 509 | 515 | ||
| 516 | export const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { | ||
| 517 | payload: PullRequestUpdateInput, | ||
| 518 | success: Schema.Void, | ||
| 519 | error: PullRequestRpcError, | ||
| 520 | }); | ||
| 521 | |||
| 510 | export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { | 522 | export 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 | }); |
| 515 | 527 | ||
| 528 | export const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { | ||
| 529 | payload: PullRequestCommentUpdateInput, | ||
| 530 | success: Schema.Void, | ||
| 531 | error: PullRequestRpcError, | ||
| 532 | }); | ||
| 533 | |||
| 516 | export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { | 534 | export 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 | ); |
| 536 | 554 | ||
| 555 | export const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { | ||
| 556 | payload: PullRequestReactionInput, | ||
| 557 | success: Schema.Void, | ||
| 558 | error: PullRequestRpcError, | ||
| 559 | }); | ||
| 560 | |||
| 537 | export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { | 561 | export 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 { |
| 76 | export const VcsProcessExitFailureKind = Schema.Literals([ | 76 | export 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 | ]); |
| 81 | export type VcsProcessExitFailureKind = typeof VcsProcessExitFailureKind.Type; | 82 | export 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."; | ||
| 144 | 147 | ||
| 145 | return new VcsProcessExitError({ | 148 | return new VcsProcessExitError({ |
| 146 | ...context, | 149 | ...context, |