diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 5aba9783075..790be9386e6 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,17 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { + // The candidate list is a read like the detail beside it, and asking somebody for a review is + // a write like every other pull request operation. + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsReviewerCandidates)).toBe( + requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail), + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRequestReviewers)).toBe( + requiredScopeForRpcMethod(WS_METHODS.pullRequestsComment), + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 4ad28691a4f..e4a8e7cfe7b 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,23 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDiff]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, + // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only + // client pressing refresh must not be told it may not look again. + [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + // The candidate list is a read like the detail beside it; asking somebody for a review is a + // write like every other one. + [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index b86f6b43893..55d72bc6619 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -81,6 +81,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e6ec99e7e8c..f34f0f5cf4d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -31,6 +31,8 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 276ee037773..71880ec4ecf 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -45,6 +45,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; } order.push("preflight"); @@ -64,6 +66,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index b5bb8a8ff1c..6fc06b889db 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -81,6 +81,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -120,6 +122,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -223,6 +227,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -264,6 +270,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 5d95ea5f62f..7cc252d3bf2 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3341,7 +3341,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("launches setup only when creating a new PR worktree", () => + it.effect("launches setup when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3614,6 +3614,547 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); + // Nothing to fetch from, so the checkout keeps the commit it had and setup stays out of a + // worktree another thread may be sitting in. + expect(setupCalls).toHaveLength(0); + expect(result.isOnPullRequestHead).toBe(false); + }), + ); + + it.effect("refreshes a reused PR worktree onto the updated pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "stale.txt"), "stale\n"); + yield* runGit(repoDir, ["add", "stale.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused stale PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-stale"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-stale-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-stale"]); + + yield* runGit(repoDir, ["checkout", "-b", "author-push", "origin/feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "authored.txt"), "authored\n"); + yield* runGit(repoDir, ["add", "authored.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New PR head commit"]); + yield* runGit(repoDir, ["push", "origin", "author-push:feature/pr-reused-stale"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "author-push"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 84, + title: "Reused stale PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/84", + baseRefName: "main", + headRefName: "feature/pr-reused-stale", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "84", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-reused-stale"); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("runs the setup script when a reused PR worktree moves onto the new head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-setup-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]); + + yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]); + yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 85, + title: "Reused setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/85", + baseRefName: "main", + headRefName: "feature/pr-reused-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "85", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-setup"), + }); + + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-reused-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + expect(result.isOnPullRequestHead).toBe(true); + }), + ); + + it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-current"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n"); + yield* runGit(repoDir, ["add", "reused-current.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-current-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]); + const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 95, + title: "Reused current PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/95", + baseRefName: "main", + headRefName: "feature/pr-reused-current", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "95", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-current"), + }); + + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "first\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Force-pushed PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-force-pushed"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-force-pushed-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-force-pushed"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "author-rewrite", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten PR head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "author-rewrite:feature/pr-force-pushed", + ]); + const rewrittenHead = (yield* runGit(repoDir, ["rev-parse", "author-rewrite"])).stdout.trim(); + // Pushing from this clone also advanced its remote-tracking ref. A head rewritten by the + // author leaves that ref behind, which is the state a reused worktree is really opened in. + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-force-pushed", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 86, + title: "Force-pushed PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/86", + baseRefName: "main", + headRefName: "feature/pr-force-pushed", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "86", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + rewrittenHead, + ); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe( + "rewritten\n", + ); + }), + ); + + it.effect("keeps a reused PR worktree that carries its own commit off the rewritten head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "first\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local commit PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-commit"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-local-commit-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-local-commit"]); + const upstreamHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "local-commit-rewrite", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten local commit head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "local-commit-rewrite:feature/pr-local-commit", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-local-commit", + upstreamHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + // The work that must survive: a commit made in the worktree, on top of the stale head. + NodeFS.writeFileSync(NodePath.join(worktreePath, "thread-work.txt"), "thread work\n"); + yield* runGit(worktreePath, ["add", "thread-work.txt"]); + yield* runGit(worktreePath, ["commit", "-m", "Work done in the reused worktree"]); + const worktreeHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 87, + title: "Local commit PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/87", + baseRefName: "main", + headRefName: "feature/pr-local-commit", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "87", + mode: "worktree", + threadId: asThreadId("thread-pr-local-commit"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(worktreeHead); + expect(NodeFS.existsSync(NodePath.join(worktreePath, "thread-work.txt"))).toBe(true); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("keeps a dirty reused PR worktree off the rewritten pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "first\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Dirty worktree PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-dirty-worktree"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-dirty-worktree-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-dirty-worktree"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "dirty-rewrite", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten dirty head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "dirty-rewrite:feature/pr-dirty-worktree", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-dirty-worktree", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + NodeFS.writeFileSync(NodePath.join(worktreePath, "dirty.txt"), "uncommitted edit\n"); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 89, + title: "Dirty worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/89", + baseRefName: "main", + headRefName: "feature/pr-dirty-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "89", + mode: "worktree", + threadId: asThreadId("thread-pr-dirty-worktree"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(staleHead); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "dirty.txt"), "utf8")).toBe( + "uncommitted edit\n", + ); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("refreshes a reused PR worktree that has no upstream from the pull request ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-ref-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Pull ref only PR branch"]); + // The head lives at refs/pull/90/head and nowhere else, so nothing can be tracked. + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/90/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-ref-only"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 90, + title: "Pull ref only PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/90", + baseRefName: "main", + headRefName: "feature/pr-ref-only", + state: "open", + }, + }, + }); + + const created = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + const worktreePath = created.worktreePath as string; + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode, + ).not.toBe(0); + + yield* runGit(repoDir, ["fetch", "origin", "refs/pull/90/head"]); + yield* runGit(repoDir, ["checkout", "-b", "ref-only-author", "FETCH_HEAD"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only again\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New pull ref head"]); + yield* runGit(repoDir, ["push", "origin", "ref-only-author:refs/pull/90/head"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "ref-only-author"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("never moves an unrelated local branch that shares the fork head branch name", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-collision"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "contributor.txt"), "contributor\n"); + yield* runGit(repoDir, ["add", "contributor.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Contributor commit on the fork main"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-collision:main"]); + // The user's own main, checked out in its own worktree and behind the fork's main: a + // fast-forward would land the contributor's commits in it. + yield* runGit(repoDir, ["checkout", "-b", "feature/root-work", "main"]); + const mainWorktreePath = NodePath.join( + repoDir, + "..", + `local-main-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", mainWorktreePath, "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 94, + title: "Fork main collision PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/94", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "94", + mode: "worktree", + threadId: asThreadId("thread-pr-fork-main-collision"), + }); + + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect((yield* runGit(mainWorktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + localMainBefore, + ); + expect(NodeFS.existsSync(NodePath.join(mainWorktreePath, "contributor.txt"))).toBe(false); + expect(result.isOnPullRequestHead).toBe(false); expect(setupCalls).toHaveLength(0); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 553eda7bb9c..b4d1240c6a2 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1849,6 +1849,7 @@ export const make = Effect.gen(function* () { pullRequest, branch: details.branch ?? pullRequest.headBranch, worktreePath: null, + isOnPullRequestHead: true, }; } @@ -1873,6 +1874,102 @@ export const make = Effect.gen(function* () { const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + // Git refuses to move a branch that is checked out in a worktree, so the + // reuse paths cannot go through materializePullRequestHeadBranch and instead + // advance the checkout from inside the worktree. A worktree that cannot be + // moved (no reachable head, local commits, dirty tree) is still handed + // back, because stranding the thread is worse than reporting the staleness. + const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* ( + worktreePath: string, + checkedOutBranch: string, + ) { + if (checkedOutBranch !== localPullRequestBranch) { + // findLocalHeadBranch also accepts a branch that merely shares the head's bare name — + // a fork PR opened from "main" matches the user's own local main. That checkout is + // somebody else's work, so it keeps its tracking config and nothing else. + yield* ensureExistingWorktreeUpstream(worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: false, + }; + } + + // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref, + // and once that has jumped to a rewritten head there is no way left to tell a checkout + // that holds nothing of its own from one carrying local commits. + const upstreamCommitBeforeFetch = yield* gitCore + .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => null), + ); + + yield* ensureExistingWorktreeUpstream(worktreePath); + + const refreshed = yield* gitCore + // The pull request's own ref, because it is the only thing that certainly names its + // head. The branch's upstream does not: configuring it is best-effort, so a branch cut + // from `origin/main` whose head branch has since been deleted still resolves — and + // following it would move the checkout onto main and call that the pull request. + .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number }) + .pipe( + // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, + // taken only where it is the head branch's own rather than whatever the checkout + // happened to be cut from. + Effect.catch(() => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + if ( + details.upstreamRef === null || + !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) + ) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: worktreePath, + detail: "The pull request head could not be resolved for this checkout.", + }); + } + return yield* gitCore.resolveCommit({ + cwd: worktreePath, + revision: details.upstreamRef, + }); + }), + ), + Effect.flatMap((target) => + gitCore.refreshCheckedOutBranch({ + cwd: worktreePath, + targetCommit: target.commitSha, + resetWhenHeadCommit: upstreamCommitBeforeFetch, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "GitManager.preparePullRequestThread reused worktree refresh failed", + { + worktreePath, + localBranch: localPullRequestBranch, + cause: error, + }, + ).pipe(Effect.as({ moved: false, onTarget: false })), + ), + ); + + // Only when the checkout actually moved: another thread may be running in this worktree, + // and re-running the setup script under it buys nothing when the code did not change. + if (refreshed.moved) { + yield* maybeRunSetupScript(worktreePath); + } + + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: refreshed.onTarget, + }; + }); + const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { const result = yield* gitCore.listRefs({ cwd, refresh: true }); const localBranch = result.refs.find( @@ -1907,12 +2004,10 @@ export const make = Effect.gen(function* () { existingBranchBeforeFetch?.worktreePath && existingBranchBeforeFetchPath !== rootWorktreePath ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; + return yield* reuseExistingWorktree( + existingBranchBeforeFetch.worktreePath, + existingBranchBeforeFetch.name, + ); } if (existingBranchBeforeFetchPath === rootWorktreePath) { return yield* new GitManagerError({ @@ -1937,12 +2032,10 @@ export const make = Effect.gen(function* () { existingBranchAfterFetch?.worktreePath && existingBranchAfterFetchPath !== rootWorktreePath ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, - }; + return yield* reuseExistingWorktree( + existingBranchAfterFetch.worktreePath, + existingBranchAfterFetch.name, + ); } if (existingBranchAfterFetchPath === rootWorktreePath) { return yield* new GitManagerError({ @@ -1965,6 +2058,7 @@ export const make = Effect.gen(function* () { pullRequest, branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, + isOnPullRequestHead: true, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index c1ee2b2cb0c..16b5625d469 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -13,6 +13,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, + decodeUtf8, type CollectedUint8StreamText, } from "./stream/collectUint8StreamText.ts"; @@ -41,6 +42,8 @@ export interface ProcessRunOutput { readonly timedOut: boolean; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + readonly stdoutInvalidUtf8: boolean; + readonly stderrInvalidUtf8: boolean; } const ProcessInvocationFields = { @@ -238,7 +241,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { ), Effect.map( (state): CollectedUint8StreamText => ({ - text: Buffer.concat(state.chunks, state.bytes).toString("utf8"), + ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), bytes: state.bytes, truncated: false, }), @@ -268,6 +271,8 @@ function finalizeRunProcess( timedOut: true, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, } satisfies ProcessRunOutput); } return Effect.fail( @@ -394,6 +399,8 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( timedOut: false, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, + stdoutInvalidUtf8: stdout.invalidUtf8, + stderrInvalidUtf8: stderr.invalidUtf8, } satisfies ProcessRunOutput; }); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts new file mode 100644 index 00000000000..b52b6d497d6 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -0,0 +1,507 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + AzureDevOpsPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function pullRequestRows( + count: number, + firstNumber: number, +): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + pullRequestId: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + repository: { name: "web", project: { name: "platform" } }, + url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, + })); +} + +function pullRequests(count: number, firstNumber: number): string { + return JSON.stringify(pullRequestRows(count, firstNumber)); +} + +/** The arguments of the nth az invocation. */ +function argsOfCall(index: number): ReadonlyArray { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].args; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("AzureDevOpsPullRequestCli.layer", (it) => { + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const page = yield* provider.listChangeRequests({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + query: "page", + }); + + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at + // all. The rows come back as they would have without a search, for the caller to narrow; + // nothing of the search reaches the command, where it could only mean the wrong thing. + assert.strictEqual(page.items.length, 3); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("steps over what it has already handed over, which is all Azure can be told", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + // The instant is the same cursor every other host reads; Azure has no filter for it and + // takes the count instead. + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, + }); + + const args = argsOfCall(0); + expect(args).toContain("--skip"); + assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); + expect(args).not.toContain("2026-07-02T00:00:00Z"); + }), + ); + + it.effect("reports truncation from the extra row", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 10); + }), + ); + + it.effect("advances by malformed raw rows and keeps reading until the page is full", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { pullRequestId: "malformed" }, + pullRequestRows(1, 1)[0], + { pullRequestId: "also malformed" }, + ]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.isTrue(batch.truncated); + // Three raw rows from the first request and one from the second produced this page. + assert.strictEqual(batch.cursorAdvance, 4); + const secondArgs = argsOfCall(1); + assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); + assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "closed", + involvement: "authored", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--creator"); + expect(argsOfCall(0)).toContain("bilal@acme.dev"); + // Azure calls a closed pull request abandoned. + expect(argsOfCall(0)).toContain("abandoned"); + }), + ); + + it.effect("asks Azure for every status on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "all", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--status"); + expect(argsOfCall(0)).toContain("all"); + }), + ); + + it.effect("narrows to the reviewer on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "reviewing", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--reviewer"); + }), + ); + + it.effect("reads the signed-in account, which az reports as a bare value", () => + Effect.gen(function* () { + // `--query user` unwraps the object, so the wrapper has to put it back. + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const viewer = yield* cli.getViewer({ cwd: "/w" }); + + assert.strictEqual(viewer, "bilal@acme.dev"); + expect(argsOfCall(0)).toEqual([ + "account", + "show", + "--query", + "user", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("fails when nobody is signed in", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); + }), + ); + + it.effect("completes a pull request to merge it, squashing only when asked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--status", + "completed", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect.each([ + { action: "draft", expected: ["--draft", "true"] }, + { action: "ready", expected: ["--draft", "false"] }, + { action: "close", expected: ["--status", "abandoned"] }, + { action: "reopen", expected: ["--status", "active"] }, + ] as const)("moves a pull request with $action", ({ action, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("reads the conversation through the REST API, pinned to a version", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + comments: [ + { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, + ], + }, + ], + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads", + }); + + assert.strictEqual(comments.length, 1); + expect(argsOfCall(0)).toContain("rest"); + expect(argsOfCall(0)).toContain( + "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1", + ); + }), + ); + + it.effect("reports a pull request it cannot place as its own outcome", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // Well-formed, but with nothing to build a link from: not a decode failure. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); + }), + ); + + it.effect("fails the read when az returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); + }), + ); + + it.effect("adds reviewers with the one command Azure has for it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test", "hubot@acme.test"], + requested: true, + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "reviewer", + "add", + "--detect", + "true", + "--id", + "42", + "--reviewers", + "octocat@acme.test", + "hubot@acme.test", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("takes a reviewer off the pull request with the same command's counterpart", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test"], + requested: false, + }); + + expect(argsOfCall(0)).toContain("remove"); + }), + ); + + it.effect("refuses a reviewer az would read as a flag, before running anything", () => + Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip( + cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["--query"], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts new file mode 100644 index 00000000000..43f929163db --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -0,0 +1,487 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestComment, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeMethod, +} from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import { + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, + type AzureDevOpsPullRequest, +} from "./azureDevOpsPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class AzureDevOpsPullRequestReadError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestReadError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Azure CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Azure CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: az answered, the account it answered for just has no name. */ +export class AzureDevOpsViewerUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsViewerUnavailableError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "Azure CLI returned no account for the current sign-in."; + } + + override get message(): string { + return `Azure CLI failed in getViewer: ${this.detail}`; + } +} + +/** + * Not a decode failure either: az answered with a well-formed pull request that simply carries + * no branch or link, which is a response this cannot place rather than one it cannot read. + */ +export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestIncompleteError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return "Azure DevOps returned no branch or link for the pull request."; + } + + override get message(): string { + return `Azure CLI failed in getPullRequest: ${this.detail}`; + } +} + +/** + * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The + * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no + * other way — so anything that could leave the value position is refused rather than sent. + */ +export class AzureDevOpsReviewerNameError extends Schema.TaggedErrorClass()( + "AzureDevOpsReviewerNameError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "A reviewer is named by an email address or an identity id."; + } + + override get message(): string { + return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; + } +} + +export type AzureDevOpsPullRequestCliError = + | AzureDevOpsCli.AzureDevOpsCliError + | AzureDevOpsPullRequestReadError + | AzureDevOpsPullRequestIncompleteError + | AzureDevOpsReviewerNameError + | AzureDevOpsViewerUnavailableError; + +/** The version every REST call below is pinned to, so a new default cannot reshape a response. */ +const REST_API_VERSION = "7.1"; + +export class AzureDevOpsPullRequestCli extends Context.Service< + AzureDevOpsPullRequestCli, + { + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Where to carry on from. Azure has no date filter for a pull request listing, so the only + * part of a cursor it can use is how many rows have already been handed over. + */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** Raw Azure rows consumed to produce this page, including malformed rows. */ + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ + readonly listThreads: (input: { + readonly cwd: string; + readonly threadsUrl: string; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + /** + * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole + * of what Azure offers here: it adds and removes named identities, and has no counterpart that + * says who could be named. + */ + readonly setPullRequestReviewers: (input: { + readonly cwd: string; + readonly number: number; + readonly reviewers: ReadonlyArray; + readonly requested: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/AzureDevOpsPullRequestCli") {} + +function statusArgs(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["--status", "active"]; + case "merged": + return ["--status", "completed"]; + case "closed": + return ["--status", "abandoned"]; + case "all": + return ["--status", "all"]; + } +} + +function involvementArgs(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return ["--creator", input.viewer]; + case "reviewing": + return ["--reviewer", input.viewer]; + case "all": + return []; + } +} + +/** + * Azure moves a pull request by setting its state rather than by named commands: completing it + * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a + * completion option rather than a strategy of its own. + */ +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; + case "ready": + return ["--draft", "false"]; + case "draft": + return ["--draft", "true"]; + case "close": + return ["--status", "abandoned"]; + case "reopen": + return ["--status", "active"]; + } +} + +/** + * A reviewer Azure could be given: an email address, a display name or an identity guid, and + * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that + * looks like a flag stops being a value. + */ +function isReviewerName(value: string): boolean { + const name = value.trim(); + return name.length > 0 && !name.startsWith("-"); +} + +export const make = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + // Every command resolves the organization, project and repository from the checkout, which is + // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows + // how to read all of them. + const detectArgs = ["--detect", "true"] as const; + + const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray }) => + azure.execute({ + cwd: input.cwd, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + /** + * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and + * retain the raw count so the next public cursor skips every row this walk consumed. + */ + const listPullRequestPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly skip: number; + readonly cursorAdvance: number; + readonly items: ReadonlyArray; + }): Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + > => { + const remaining = input.limit - input.items.length; + const top = remaining + 1; + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--repository", + input.repository, + ...statusArgs(input.state), + ...involvementArgs(input), + // A web link per row, which is the only url that needs no assembling. + "--include-links", + ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), + "--top", + String(top), + ], + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.items, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodePullRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + + const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; + if (lastItemIndex !== undefined) { + const consumed = lastItemIndex + 1; + return Effect.succeed({ + items: [...input.items, ...decoded.success.items.slice(0, remaining)], + // A full raw response may have more rows even when malformed entries used the probe. + truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + + const items = [...input.items, ...decoded.success.items]; + if (decoded.success.rawCount < top) { + return Effect.succeed({ + items, + truncated: false, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + }); + } + return listPullRequestPage({ + ...input, + skip: input.skip + decoded.success.rawCount, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + items, + }); + }), + ); + }; + + return AzureDevOpsPullRequestCli.of({ + getViewer: (input) => + executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( + Effect.flatMap((result): Effect.Effect => { + // `--query user` narrows the payload to the account, so it is nested back under the + // key the decoder reads to keep one shape for the signed-in user. + const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getViewer", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + listPullRequestPage({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + // Azure counts rather than filters, so a slice carries on by stepping over every raw row + // the prior slice consumed. That is an offset into a list that can shift underneath it: + // a pull request opened between two slices moves everything down one, and the row on the + // seam is the one that pays for it. + skip: input.cursor?.delivered ?? 0, + cursorAdvance: 0, + items: [], + }), + + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], + }).pipe( + Effect.flatMap( + (result): Effect.Effect => { + const decoded = decodePullRequestJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getPullRequest", + cause: decoded.failure, + }), + ); + } + // Null means Azure answered with too little to place the pull request. Nothing + // failed underneath it, so it is its own outcome rather than a decode failure. + return decoded.success === null + ? Effect.fail( + new AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }, + ), + ), + + listThreads: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "rest", + "--method", + "get", + "--url", + `${input.threadsUrl}?api-version=${REST_API_VERSION}`, + ], + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeThreadsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listThreads", + cause: decoded.failure, + }), + ); + }), + ), + + setPullRequestReviewers: (input) => + input.reviewers.some((reviewer) => !isReviewerName(reviewer)) + ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) + : azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "reviewer", + input.requested ? "add" : "remove", + ...detectArgs, + "--id", + String(input.number), + // One `--reviewers` takes them all, because az reads the flag as a list and a + // second one would replace the first rather than add to it. + "--reviewers", + ...input.reviewers, + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + + runPullRequestAction: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + ...actionArgs(input.action, input.mergeMethod), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 00000000000..cce581ce6c9 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; + +describe("azure devops viewer permissions", () => { + it("offers every action to whoever is signed in, because Azure names no permission", () => { + // The same answer for a viewer who can write, one who can only read, and an author with read + // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, + // and an unknown permission is granted rather than guessed away. Azure refuses the ones it + // will not allow, at the moment they are taken, in words this could not have written. + expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ + actions: ["merge", "ready", "draft", "close", "reopen"], + // False because the host itself cannot post one, not because this viewer may not. + comment: false, + resolve: false, + verdicts: [], + // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. + requestReviewers: true, + }); + }); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts new file mode 100644 index 00000000000..dd3c8a4aa0f --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -0,0 +1,252 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + // `az repos pr` has no diff command, and the REST route reports changed files without their + // contents, so there is no patch to show. The Code tab is hidden rather than empty. + diff: false, + // Reading a conversation is a plain REST read, but posting one is not something this can + // claim without having run it, so the composer stays hidden. + comment: false, + actions: ["merge", "ready", "draft", "close", "reopen"], + // Azure squashes as a completion option; it has no rebase strategy of its own. + mergeMethods: ["merge", "squash"], + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. + search: false, + // With no patch to show there are no lines to write against, so nothing here is offered. + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` + // lists the ones this repository could name — that lives behind the identity and graph APIs, a + // different service with its own permissions. So the page takes a name here rather than being + // handed a menu built out of a guess. + reviewers: { request: true, listCandidates: false }, +}; + +/** + * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission + * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security + * namespaces, behind identity descriptors and token paths that would be several calls per pull + * request to resolve. + * + * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment + * they try. That is the safer half of an unknown: hiding a control from someone entitled to it + * leaves them no way through and no reason given. + */ +export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { + actions: CAPABILITIES.actions, + comment: CAPABILITIES.comment, + resolve: CAPABILITIES.review.resolve, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: CAPABILITIES.reviewers.request, +}; + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "AzureDevOpsCliUnavailableError") return "missing-tool"; + if (error._tag === "AzureDevOpsCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Azure reports no line counts on a pull request, and with no patch to read there is + // nothing to count them from either. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Azure keeps labels on work items rather than on the pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const fail = + (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + /** Refuses what the capabilities already say this host cannot do. */ + const unsupported = (operation: string) => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Azure DevOps reviews cannot be written from here yet.", + }), + ); + + const provider: PullRequestProviderApi = { + kind: "azure-devops", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, + // reviewer and branch, and has nothing that matches text. Sending it as one of those would + // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + cursorAdvance: batch.cursorAdvance, + // Azure answers in one order whether or not it is being carried on from, so a slice + // can always be stepped past — by counting, which is all Azure offers. + continues: true, + })), + ), + + getChangeRequest: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequest")), + // The conversation hangs off a url the pull request itself reports, so it is read after + // rather than alongside. A thread read that fails degrades to none, marked truncated so + // it does not present as a pull request nobody has commented on. + Effect.flatMap((pullRequest) => + (pullRequest.threadsUrl === null + ? Effect.succeed({ comments: [], truncated: true }) + : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe( + Effect.map((comments) => ({ comments, truncated: false })), + Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + ) + ).pipe( + Effect.map( + (conversation): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: 0, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + // Azure reports its gates as branch policy evaluations, which are a separate + // read this does not make yet. + checks: [], + comments: conversation.comments, + // Azure hands its whole thread collection over in one response, so a read that + // succeeded holds all of it and the count is exact. + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + // No patch to pin a conversation to, so nothing here is anchored to a line. + reviewThreads: [], + // `az repos pr show` carries no commit list. + commits: [], + // Azure publishes no per-strategy availability on the pull request: which + // strategies a repository allows lives in its branch policies, which `az repos + // pr show` does not read. So both strategies Azure has are offered, and one a + // policy forbids is refused at completion — with the host's own sentence, which + // is the only place that reason exists. Reading `az repos policy list` per + // repository would let the control be hidden instead; that is a second call per + // pull request for a case the completion error already names. + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + }), + ), + ), + ), + ), + + // No request at all: Azure has nothing to say about the viewer that a pull request read can + // reach, so the answer is the same constant the detail carries. + getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), + + // Never called: `capabilities.diff` is false, and the service refuses a diff without it. + getDiff: () => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "getDiff", + reason: "failed", + detail: "Azure DevOps cannot produce a patch for a pull request.", + }), + ), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the + // list without it. + listReviewerCandidates: () => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "listReviewerCandidates", + reason: "failed", + detail: "Azure DevOps cannot say who may review a pull request.", + }), + ), + + setReviewerRequest: (input) => + cli + .setPullRequestReviewers({ + cwd: input.cwd, + number: input.number, + // Azure names an identity by an email address or a guid, and has no team to ask, so a + // candidate's id is the whole of what it takes. + reviewers: input.reviewers.map((reviewer) => reviewer.id), + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + // Never called: `capabilities.comment` is false, and the service refuses a comment without it. + comment: () => unsupported("comment"), + + // Declared unsupported above, so the service refuses these before a provider is reached. + // They exist because every provider answers the whole port. + submitReview: () => unsupported("submitReview"), + + replyToThread: () => unsupported("replyToThread"), + + setThreadResolution: () => unsupported("setThreadResolution"), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts new file mode 100644 index 00000000000..1248b396956 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -0,0 +1,874 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; + +const mockedRequest = vi.fn(); + +const layer = it.layer( + BitbucketPullRequestApi.layer.pipe( + Layer.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + request: mockedRequest, + }), + ), + ), +); + +/** The shape `request` answers with: a body plus whether it had to be cut short. */ +function response(body: string) { + return { body, truncated: false }; +} + +function page(count: number, firstNumber: number, next?: string): string { + return JSON.stringify({ + pagelen: 50, + size: count, + values: Array.from({ length: count }, (_, index) => ({ + id: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + state: "OPEN", + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, + })), + ...(next === undefined ? {} : { next }), + }); +} + +function valuePage(values: ReadonlyArray, next?: string): string { + return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); +} + +/** Who opened the pull request, and two accounts that could review it. */ +const bilal = { uuid: "{bilal}", nickname: "bilal" }; +const octocat = { uuid: "{octocat}", nickname: "octocat" }; +const hubot = { uuid: "{hubot}", nickname: "hubot" }; + +/** One pull request as `/pullrequests/{id}` answers with it. */ +function pullRequestJson(overrides: Record): string { + return JSON.stringify({ + id: 7, + title: "Pull request 7", + state: "OPEN", + author: bilal, + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, + ...overrides, + }); +} + +/** The request the nth call made. */ +function callAt(index: number) { + const call = mockedRequest.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The filter expression of the nth request, read back out of its query string. */ +function filterOfCall(index: number): string | null { + const url = callAt(index).url; + return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); +} + +afterEach(() => { + mockedRequest.mockReset(); +}); + +layer("BitbucketPullRequestApi.layer", (it) => { + it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const url = callAt(0).url; + expect(url).toContain("/repositories/acme/web/pullrequests"); + expect(url).toContain("state=OPEN"); + // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. + expect(url).toContain("pagelen=50"); + expect(url).toContain("sort=-updated_on"); + expect(url).toContain("fields=%2Bvalues.reviewers"); + }), + ); + + it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 100, + }); + + assert.strictEqual(batch.items.length, 100); + assert.isFalse(batch.truncated); + assert.strictEqual(callAt(1).url, next); + }), + ); + + it.effect("stops at the caller's page and says more remain", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 50); + assert.isTrue(batch.truncated); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("counts the rows it walked past as more to come", () => + Effect.gen(function* () { + // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a + // hundred and drops one. That row is more results, and saying otherwise takes the "load + // more" away from a listing that has not finished. + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("searches with a filter expression, which is all Bitbucket offers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + }); + + expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); + // The state filter beside it still stands, which the brackets are there to keep. + expect(callAt(0).url).toContain("state=OPEN"); + }), + ); + + it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: String.raw`a\" OR state = "MERGED"`, + }); + + const literal = String.raw`a\\\" OR state = \"MERGED\"`; + expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); + }), + ); + + it.effect("asks for no filter at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: " ", + }); + + assert.isNull(filterOfCall(0)); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop. + expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); + expect(callAt(0).url).toContain("sort=-updated_on"); + }), + ); + + it.effect("narrows by the reader's words and by where it left off at once", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, + }); + + // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the + // other — and the search keeps its brackets, which is what keeps the AND out of its OR. + expect(filterOfCall(0)).toBe( + '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', + ); + }), + ); + + it.effect("asks for declined pull requests on the closed tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + }), + ); + + it.effect("asks for every state at once on the All tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); + + // Bitbucket unions repeated state parameters, which is the only way to span them. + const url = callAt(0).url; + for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { + expect(url).toContain(`state=${state}`); + } + }), + ); + + it.effect("counts a superseded pull request as closed", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + expect(callAt(0).url).toContain("state=SUPERSEDED"); + }), + ); + + it.effect("refuses a repository that is not workspace and slug", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), + ); + + assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => + Effect.gen(function* () { + const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); + + assert.strictEqual(diff.patch, patch); + assert.isFalse(diff.truncated); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/pullrequests/7/diff", + // A diff of any size would otherwise be read into memory whole. + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => + Effect.gen(function* () { + const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + assert.strictEqual(diff.patch, patch); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "../../acme/other/diff/deadbeef", + }), + ); + + assert.strictEqual(error._tag, "BitbucketDiffCommitError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("aggregates every diffstat page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 3, lines_removed: 1 }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); + + expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns the complete commit timeline oldest first across pages", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/commits?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, + { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage([ + { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, + { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, + ]), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns build statuses from every page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/statuses?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Build", "success"], + ["Lint", "failure"], + ]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("reads an empty conflict list as mergeable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mergeability, "mergeable"); + expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); + }), + ); + + it.effect("merges with Bitbucket's own name for the strategy", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "rebase", + }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/merge", + body: '{"merge_strategy":"rebase_fast_forward"}', + }); + }), + ); + + it.effect("closes a pull request by declining it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/decline", + }); + }), + ); + + it.effect("posts a comment as a JSON document, so the body stays text", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/comments", + body: '{"content":{"raw":"true"}}', + }); + }), + ); + + it.effect("fails the read when Bitbucket answers with something unreadable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(response(JSON.stringify({ error: "nope" }))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); + + assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); + }), + ); + + it.effect("states a failure once, without stacking one message inside another", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 500, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + // The fact only; the provider adds the operation around it. + assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); + }), + ); + + it.effect("fails when the credentials belong to no named account", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); + }), + ); + + it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12 }, + }, + ], + }), + ), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // The reply arrives a page after the remark it answers, which is why the threads + // are only assembled once every page is in hand. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + values: [ + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { comments, threads, truncated } = yield* api.listComments({ + repository: "acme/web", + number: 7, + }); + + expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); + expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the comment walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // Bitbucket that always names a next page: the walk has to end itself. + mockedRequest.mockReturnValue( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "again" }, + created_on: "2026-06-16T05:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mockedRequest.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reassembles a thread from the flat comment list, replies included", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12, from: null }, + resolution: { type: "pullrequest_comment_resolution" }, + }, + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + // A reply to a reply still belongs to the thread its root opened. + { + id: 12, + content: { raw: "thanks" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T07:04:32+00:00", + parent: { id: 11 }, + }, + { + id: 13, + content: { raw: "ship it" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T08:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "10", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); + }), + ); + + it.effect("writes a review's line comments, its summary, then its verdict", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.submitReview({ + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "Two things.", + comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + }); + + expect(callAt(0).url).toContain("/pullrequests/7/comments"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "why remove?" }, + inline: { path: "src/a.ts", from: 12 }, + }); + expect(callAt(1).url).toContain("/pullrequests/7/comments"); + // The verdict goes last, so a review that failed part-way is never a rejection either. + expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); + }), + ); + + it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: true, + }); + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: false, + }); + + assert.strictEqual(callAt(0).method, "POST"); + assert.strictEqual(callAt(1).method, "DELETE"); + expect(callAt(0).url).toContain("/comments/10/resolve"); + }), + ); + + it.effect("replies by naming the comment it answers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.replyToComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Fixed.", + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "Fixed." }, + parent: { id: 10 }, + }); + }), + ); + + it.effect("asks for the credentials' permission on this repository, and nobody else's", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); + + expect(callAt(0).url).toContain("/user/permissions/repositories"); + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); + }), + ); + + it.effect("escapes a repository name before it goes inside a filter literal", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); + + // A quote would otherwise end the literal and leave the rest standing as filter syntax. + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); + }), + ); + + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); + + // The people live on the workspace: nothing on a repository lists who may review it. + expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["{octocat}", true], + ["{hubot}", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: true, + }); + + // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with + // the new one or the request would take them off it. + const call = callAt(1); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.body ?? "")).toEqual({ + reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], + }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), + ) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts new file mode 100644 index 00000000000..7c0a7d11744 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -0,0 +1,785 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestListState, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { + buildReviewThreads, + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, + decodeWorkspaceMembersJson, + type BitbucketDiffStat, + type BitbucketPullRequest, + type BitbucketRawComment, +} from "./bitbucketPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class BitbucketPullRequestReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestReadError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Bitbucket returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Bitbucket failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ +export class BitbucketViewerUnavailableError extends Schema.TaggedErrorClass()( + "BitbucketViewerUnavailableError", + {}, +) { + get detail(): string { + return "Bitbucket returned no account name for the configured credentials."; + } + + override get message(): string { + return `Bitbucket failed in getViewer: ${this.detail}`; + } +} + +/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ +export class BitbucketRepositoryUnsupportedError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryUnsupportedError", + { + repository: Schema.String, + }, +) { + get detail(): string { + return "A Bitbucket repository is addressed as workspace/repository."; + } + + override get message(): string { + return `Bitbucket failed in resolveRepository: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class BitbucketDiffCommitError extends Schema.TaggedErrorClass()( + "BitbucketDiffCommitError", + {}, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; + } +} + +export type BitbucketPullRequestApiError = + | BitbucketApi.BitbucketApiError + | BitbucketPullRequestReadError + | BitbucketViewerUnavailableError + | BitbucketRepositoryUnsupportedError + | BitbucketDiffCommitError; + +/** + * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no + * error at all, so this is a number to respect rather than to push against. + */ +const MAX_PAGE_SIZE = 50; +/** Pages to walk before a listing is reported as truncated. */ +const MAX_LIST_PAGES = 10; +/** The page size for pull request conversations, commits, and checks. */ +const CONVERSATION_PAGE_SIZE = 50; +/** + * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves + * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, + * and an end to a walk whose only other stop is Bitbucket running out. + */ +const CONVERSATION_PAGES = 10; +/** The same ceiling the gh and glab diff reads use. */ +const DIFF_MAX_BYTES = 8 * 1024 * 1024; + +export interface BitbucketPullRequestBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class BitbucketPullRequestApi extends Context.Service< + BitbucketPullRequestApi, + { + /** A function rather than a value, so the request is built per call and not at layer time. */ + readonly getViewer: () => Effect.Effect; + + readonly listPullRequests: (input: { + readonly repository: string; + readonly state: PullRequestListState; + readonly limit: number; + /** Free text, matched against a pull request's title and description. */ + readonly query?: string | undefined; + /** Where to carry on from, as a predicate on `updated_on` beside any other. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + readonly getPullRequest: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + /** True where the credentials can write to the repository, which is what merging needs. */ + readonly getRepositoryPermission: (input: { + readonly repository: string; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly repository: string; + readonly number: number; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + >; + + readonly getDiffStat: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getMergeability: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listComments: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + >; + + readonly listCommits: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + readonly listChecks: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + /** + * Who this pull request may be sent to, and who it has already been sent to. Two reads at + * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull + * request, and neither answers for the other. + */ + readonly listReviewerCandidates: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runAction: (input: { + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly comment: (input: { + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly repository: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setCommentResolution: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly resolved: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/BitbucketPullRequestApi") {} + +/** `workspace/slug`; Bitbucket has no deeper nesting to address. */ +function repositorySegments( + repository: string, +): Result.Result< + { readonly workspace: string; readonly slug: string }, + BitbucketRepositoryUnsupportedError +> { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + const [workspace, slug] = segments; + if (segments.length !== 2 || workspace === undefined || slug === undefined) { + return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); + } + return Result.succeed({ workspace, slug }); +} + +function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { + return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( + segments.slug, + )}`; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks + * for each. It separates a declined pull request from one superseded by another, and both read + * as closed here. + */ +function stateParams(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "merged": + return ["MERGED"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +/** + * Bitbucket has no search term, only a filter expression, so free text becomes one: a + * case-insensitive contains against the two fields a pull request carries words in. The + * parentheses matter, because the expression is ANDed with the state filter beside it and an + * unbracketed `OR` would swallow it. + * + * A string literal in that grammar is delimited by double quotes, so the reader's text is + * escaped before it goes inside one — a quote would otherwise end the literal and leave the + * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so + * nothing in it reaches the query string as a parameter of its own. + */ +function searchFilter(query: string): string { + const literal = filterLiteral(query); + return `(title ~ "${literal}" OR description ~ "${literal}")`; +} + +/** + * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or + * escaping the quote would only produce a literal backslash followed by a live quote. + */ +function filterLiteral(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +/** Bitbucket's merge strategies, named differently from the three the contract carries. */ +function mergeStrategy(method: PullRequestMergeMethod | undefined): string { + switch (method) { + case "squash": + return "squash"; + case "rebase": + // The linear history GitHub calls "rebase and merge". + return "rebase_fast_forward"; + default: + return "merge_commit"; + } +} + +export const make = Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + /** + * The repository's own path, and the workspace above it — which the people who may review are + * kept on rather than on the repository, so both are handed over at once. + */ + const withRepository = ( + repository: string, + use: (path: string, workspace: string) => Effect.Effect, + ): Effect.Effect => { + const segments = repositorySegments(repository); + return Result.isSuccess(segments) + ? use(repositoryPathOf(segments.success), segments.success.workspace) + : Effect.fail(segments.failure); + }; + + /** + * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it + * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at + * the page cap — and anything but running out of pages means there is more to be had. + */ + const listPage = (input: { + readonly url: string; + readonly limit: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = decodePullRequestPageJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.items]; + const next = decoded.success.next; + if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { + return Effect.succeed({ + items: collected.slice(0, input.limit), + // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the + // count rather than on the last page is holding rows it is about to drop. Those are + // more results just as surely as another page would be. + truncated: next !== null || collected.length > input.limit, + }); + } + return listPage({ ...input, url: next, page: input.page + 1, collected }); + }), + ); + + const readPage = (input: { + readonly operation: string; + readonly url: string; + readonly decode: (body: string) => Result.Result; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = input.decode(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new BitbucketPullRequestReadError({ + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * The conversation, following the `next` Bitbucket sends until it sends none. Threads are + * assembled once at the end rather than per page, because a reply and the remark it answers + * can land either side of a page boundary. + */ + const commentsPage = (input: { + readonly url: string; + readonly page: number; + readonly comments: ReadonlyArray; + readonly entries: ReadonlyArray; + }): Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + > => + readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( + Effect.flatMap((page) => { + const comments = [...input.comments, ...page.comments]; + const entries = [...input.entries, ...page.entries]; + if (page.next !== null && input.page < CONVERSATION_PAGES) { + return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); + } + return Effect.succeed({ + comments, + threads: buildReviewThreads(entries), + truncated: page.next !== null, + }); + }), + ); + + /** Walks a Bitbucket cursor to its end and combines every decoded item. */ + const itemPages = (input: { + readonly operation: string; + readonly url: string; + readonly decode: ( + body: string, + ) => Result.Result<{ readonly items: ReadonlyArray; readonly next: string | null }, unknown>; + readonly items: ReadonlyArray; + /** Commit pages are individually oldest-first, so older pages are prepended. */ + readonly prepend: boolean; + }): Effect.Effect, BitbucketPullRequestApiError> => + readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( + Effect.flatMap((page) => { + const items = input.prepend + ? [...page.items, ...input.items] + : [...input.items, ...page.items]; + return page.next === null + ? Effect.succeed(items) + : itemPages({ ...input, url: page.next, items }); + }), + ); + + /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ + const diffStatPages = (input: { + readonly url: string; + readonly totals: BitbucketDiffStat; + }): Effect.Effect => + readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( + Effect.flatMap((page) => { + const totals = { + additions: input.totals.additions + page.additions, + deletions: input.totals.deletions + page.deletions, + changedFiles: input.totals.changedFiles + page.changedFiles, + }; + return page.next === null + ? Effect.succeed(totals) + : diffStatPages({ url: page.next, totals }); + }), + ); + + return BitbucketPullRequestApi.of({ + getViewer: () => + bitbucket.request({ method: "GET", url: "/user" }).pipe( + Effect.flatMap((response): Effect.Effect => { + const decoded = decodeViewerJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), + ); + } + return decoded.success === null + ? Effect.fail(new BitbucketViewerUnavailableError()) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + withRepository(input.repository, (path) => { + const search = input.query?.trim() ?? ""; + // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one + // replacing the other. The boundary instant is read inclusively — the rows already sent + // at it come back and the caller drops them, which is what keeps their neighbours at the + // same instant from being skipped. A date is a bare literal in this grammar, and this one + // was checked against a timestamp's shape before it got here. + const predicates = [ + ...(search.length === 0 ? [] : [searchFilter(search)]), + ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), + ]; + return listPage({ + // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. + url: `${path}/pullrequests?${stateParams(input.state) + .map((state) => `state=${state}`) + .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ + predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` + }`, + limit: input.limit, + page: 1, + collected: [], + }); + }), + + getPullRequest: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + ), + + // Nothing on the repository, the pull request or the workspace states what the credentials + // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked + // alongside the reads the detail was already making, so it costs no round trip of its own. + getRepositoryPermission: (input) => + withRepository(input.repository, () => + readPage({ + operation: "getRepositoryPermission", + url: `/user/permissions/repositories?q=${encodeURIComponent( + `repository.full_name="${filterLiteral(input.repository.trim())}"`, + )}`, + decode: decodeRepositoryPermissionJson, + }), + ), + + getPullRequestDiff: (input) => + input.commit !== undefined && !isCommitSha(input.commit) + ? Effect.fail(new BitbucketDiffCommitError()) + : withRepository(input.repository, (path) => + // Already a unified patch, so it needs no decoding at all — only a bound, which a + // diff of any size would otherwise ignore. A commit's own patch sits beside the pull + // request's at `/diff/{sha}` and reads the same way. + bitbucket + .request({ + method: "GET", + url: + input.commit === undefined + ? `${path}/pullrequests/${input.number}/diff` + : `${path}/diff/${input.commit}`, + maxBytes: DIFF_MAX_BYTES, + }) + .pipe( + Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), + ), + ), + + getDiffStat: (input) => + withRepository(input.repository, (path) => + diffStatPages({ + url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, + totals: { additions: 0, deletions: 0, changedFiles: 0 }, + }), + ), + + getMergeability: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getMergeability", + url: `${path}/pullrequests/${input.number}/conflicts`, + decode: decodeConflictsJson, + }), + ), + + listComments: (input) => + withRepository(input.repository, (path) => + commentsPage({ + url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, + page: 1, + comments: [], + entries: [], + }), + ), + + listCommits: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listCommits", + url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeCommitsJson, + items: [], + prepend: true, + }), + ), + + listChecks: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listChecks", + url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeStatusesJson, + items: [], + prepend: false, + }), + ), + + listReviewerCandidates: (input) => + withRepository(input.repository, (path, workspace) => + Effect.all( + [ + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + readPage({ + operation: "listReviewerCandidates", + url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeWorkspaceMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([pullRequest, members]) => { + const requested = new Set(pullRequest.reviewerIds); + const author = pullRequest.author?.login; + return { + // The author is dropped rather than shown unusable: Bitbucket refuses to make the + // person who opened a pull request its reviewer. + candidates: members.items.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.id) }], + ), + truncated: members.next !== null, + }; + }), + ), + ), + + setReviewerRequest: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + return readPage({ + operation: "getPullRequest", + url: pullRequest, + decode: decodePullRequestJson, + }).pipe( + Effect.flatMap((current) => { + // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's + // `reviewers` is written whole, so the set that is already there is read first and + // the change applied to it. Everything else about the pull request is left out of + // the body, which leaves it as it was. + const uuids = new Set(current.reviewerIds); + for (const reviewer of input.reviewers) { + if (input.requested) uuids.add(reviewer.id); + else uuids.delete(reviewer.id); + } + return bitbucket.request({ + method: "PUT", + url: pullRequest, + body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), + }); + }), + Effect.asVoid, + ); + }), + + runAction: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Only merge and close reach here: the provider declares the others unsupported, so the + // surface never offers them. + if (input.action === "merge") { + return bitbucket + .request({ + method: "POST", + url: `${pullRequest}/merge`, + body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), + }) + .pipe(Effect.asVoid); + } + return bitbucket + .request({ method: "POST", url: `${pullRequest}/decline` }) + .pipe(Effect.asVoid); + }), + + comment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + // A JSON document rather than a form field, so the body stays text whatever it says. + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + + submitReview: (input) => + withRepository(input.repository, (path) => + Effect.gen(function* () { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Bitbucket has no pending review, so a review is replayed as the requests it is + // made of: the line comments, then the summary, then the verdict. The verdict goes + // last so a review that fails part-way is never left standing as an approval. + yield* Effect.forEach( + input.comments, + (comment) => + bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + body: JSON.stringify({ + content: { raw: comment.body }, + inline: { + path: comment.path, + ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + }, + }), + }), + { discard: true }, + ); + if (input.body.trim().length > 0) { + yield* bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: JSON.stringify({ content: { raw: input.body } }), + }); + } + if (input.verdict === "approve") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); + } + if (input.verdict === "request-changes") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); + } + }), + ), + + replyToComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + body: JSON.stringify({ + content: { raw: input.body }, + parent: { id: Number(input.commentId) }, + }), + }) + .pipe(Effect.asVoid), + ), + + setCommentResolution: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Resolving is a sub-resource that is created and deleted, rather than a field. + method: input.resolved ? "POST" : "DELETE", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}/resolve`, + }) + .pipe(Effect.asVoid), + ), + }); +}); + +export const layer = Layer.effect(BitbucketPullRequestApi, make); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts new file mode 100644 index 00000000000..7e57d6c771e --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { + bitbucketErrorReason, + bitbucketViewerPermissions, +} from "./BitbucketPullRequestProvider.ts"; + +describe("bitbucketErrorReason", () => { + it("treats only an HTTP 401 as unusable credentials", () => { + const responseError = (status: number) => + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + }); + + expect(bitbucketErrorReason(responseError(401))).toBe("unauthenticated"); + expect(bitbucketErrorReason(responseError(403))).toBe("failed"); + }); +}); + +describe("bitbucketViewerPermissions", () => { + it("offers both actions to credentials with write access", () => { + expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ + actions: ["merge", "close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + // Bitbucket says nothing about who may set a reviewer, and an unreported permission is + // granted. + requestReviewers: true, + }); + }); + + it("keeps merge from credentials that can only read the repository", () => { + expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ + actions: ["close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }); + }); + + it("treats an author with read access as any other reader, which is all Bitbucket says", () => { + // The repository permission is the whole of what Bitbucket reports per account; it says + // nothing about who opened this pull request, and its author may decline it with read access + // alone — so declining stays offered rather than being taken from them. + expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); + }); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts new file mode 100644 index 00000000000..6839e0c3b97 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -0,0 +1,279 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that + // moves one in or out of draft, so neither is offered rather than failing when pressed. + actions: ["merge", "close"], + mergeMethods: ["merge", "squash", "rebase"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, +}; + +/** + * What the configured account may do here, from the one thing Bitbucket states per viewer: the + * repository permission. Merging needs `write` or `admin`, so that is what narrows. + * + * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request + * decline their own with no more than read access, and the permission response says nothing about + * who opened this one — so withholding the control from the one person entitled to it is the + * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is + * enough to say something, to approve and to ask for changes. + * + * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the + * author of a pull request as well as from whoever can write, and says nothing here about which + * of the two this account is. + */ +export function bitbucketViewerPermissions(input: { + readonly canWrite: boolean; +}): PullRequestViewerPermissions { + return { + actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + }; +} + +/** The failures that mean the credentials are the problem, rather than one request. */ +export function bitbucketErrorReason( + error: BitbucketPullRequestApi.BitbucketPullRequestApiError, +): PullRequestProviderError["reason"] { + // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be + // missing: unusable always means the credentials are absent or refused. + if (error._tag === "BitbucketResponseError" && error.status === 401) { + return "unauthenticated"; + } + return "failed"; +} + +function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Line counts are a separate read, which only the detail is worth spending on. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Bitbucket has no labels on a pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const fail = + (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => + new PullRequestProviderError({ + provider: "bitbucket", + operation, + reason: bitbucketErrorReason(error), + // Every Bitbucket failure states its own fact; this names the operation around it, so + // the two do not stack into "failed in x: failed in y: ...". + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "bitbucket", + capabilities: CAPABILITIES, + + // Bitbucket credentials come from the server's environment rather than a checkout, so the + // account is the same whichever workspace asks. + getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + api + .listPullRequests({ + repository: input.repository, + state: input.state, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, + // so every page it answers is one a cursor can continue. + continues: true, + })), + ), + + getChangeRequest: (input) => { + const target = { repository: input.repository, number: input.number }; + // Bitbucket spreads a pull request over seven endpoints, so they are read together. + return Effect.all( + [ + api.getPullRequest(target), + api.getDiffStat(target), + // Each of these is worth degrading for: none is a reason to blank a pull request that + // was read successfully. An unread conversation counts as truncated so it does not + // present as one with no comments. + api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)), + api + .listComments(target) + .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), + api.listCommits(target).pipe(Effect.orElseSucceed(() => [])), + api.listChecks(target).pipe(Effect.orElseSucceed(() => [])), + // A permission that could not be read is an unknown one, which is granted: a hidden + // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses + // at least says why. + api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)), + ], + { concurrency: 7 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([ + pullRequest, + diffStat, + mergeability, + comments, + commits, + checks, + canWrite, + ]): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + mergeability, + additions: diffStat.additions, + deletions: diffStat.deletions, + changedFiles: diffStat.changedFiles, + body: pullRequest.body, + mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + reviewers: pullRequest.reviewers, + checks, + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + // Bitbucket's page carries a `size`, but it counts the deleted and unposted comments + // this drops, so the walk's own total is the truer count of what was said. + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + // Bitbucket publishes no per-repository list of allowed strategies, so the ones it + // supports are all offered and a strategy the repository forbids fails on merge. + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: bitbucketViewerPermissions({ canWrite }), + }), + ), + ); + }, + + getViewerPermissions: (input) => + api.getRepositoryPermission({ repository: input.repository }).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), + ), + + // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. + getDiff: (input) => + api + .getPullRequestDiff({ + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe( + Effect.mapError(fail("getDiff")), + Effect.map((diff) => ({ ...diff, nextCursor: null })), + ), + + // Users only: Bitbucket requests a review of an account, and has no group that stands in for + // one on a pull request. + listReviewerCandidates: (input) => + api + .listReviewerCandidates({ repository: input.repository, number: input.number }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + api + .setReviewerRequest({ + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + api + .runAction({ + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => + api + .comment({ repository: input.repository, number: input.number, body: input.body }) + .pipe(Effect.mapError(fail("comment"))), + + submitReview: (input) => + api + .submitReview({ + repository: input.repository, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + api + .replyToComment({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setThreadResolution: (input) => + api + .setCommentResolution({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts new file mode 100644 index 00000000000..07d79feaca1 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -0,0 +1,1715 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitHubPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitHubCli.GitHubCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated, + stderrTruncated: false, + stdoutInvalidUtf8, + }; +} + +function pullRequests( + count: number, + firstNumber: number, + overrides: (number: number) => Readonly> = () => ({}), +): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + number: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + url: `https://github.com/acme/web/pull/${firstNumber + index}`, + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...overrides(firstNumber + index), + })), + ); +} + +function pullRequestFiles(count: number, firstIndex: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + filename: `src/file${firstIndex + index}.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + })), + ); +} + +/** One thread's comments as the GraphQL read returns them, cursor and all. */ +function threadComments( + ids: ReadonlyArray, + endCursor: string | null, + totalCount = ids.length, +) { + return { + totalCount, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), + }; +} + +function thread(id: string, ...commentIds: ReadonlyArray) { + return { + id, + path: "src/a.ts", + line: 1, + diffSide: "RIGHT", + isResolved: false, + isOutdated: false, + comments: threadComments(commentIds, null), + }; +} + +function reviewThreadsPage( + nodes: ReadonlyArray>, + endCursor: string | null, +): string { + return JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { + totalCount: nodes.length, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes, + }, + }, + }, + }, + }); +} + +function threadCommentsPage( + ids: ReadonlyArray, + endCursor: string | null, + totalCount: number, +): string { + return JSON.stringify({ + data: { node: { comments: threadComments(ids, endCursor, totalCount) } }, + }); +} + +/** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */ +const diffRefused = new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"), +}); + +/** The whole invocation the nth call made, so both argv and stdin can be asserted. */ +function callAt(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The one argument `--search` carries, which is where every listing filter ends up. */ +function searchOfCall(index: number): string | undefined { + const args = callAt(index).args; + const flag = args.indexOf("--search"); + // Absent is its own answer: a read that carries no `--search` at all is what the fallback is. + return flag === -1 ? undefined : args[flag + 1]; +} + +/** One row as a search answers it, which is the listing's row one connection deeper. */ +function searchItem(number: number, repository: string, updatedAt: string) { + return { + number, + title: `Pull request ${number}`, + url: `https://github.com/${repository}/pull/${number}`, + author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, + headRefName: "feat/page", + baseRefName: "main", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + repository: { nameWithOwner: repository }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, + labels: { nodes: [{ name: "bug", color: "ff0000" }] }, + }; +} + +function searchPage(nodes: ReadonlyArray, hasNextPage = false) { + return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); +} + +/** The search a batched read sent, which travels in the request body rather than in argv. */ +function searchQueryOfCall(index: number): string | undefined { + const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } }; + return body.variables?.q; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitHubPullRequestCli.layer", (it) => { + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const args = callAt(0).args; + expect(args).toContain("--repo"); + expect(args).toContain("github.com/acme/web"); + expect(args).toContain("--state"); + expect(args).toContain("open"); + expect(args).toContain("--limit"); + expect(args).toContain("11"); + }), + ); + + it.effect("reports truncation from the extra row, counted before decoding", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("excludes merged pull requests from the Closed tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // `--state closed` includes merged pull requests, so the tab narrows through search. + expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + const args = callAt(0).args; + expect(args).toContain("--author"); + expect(args).toContain("bilal"); + }), + ); + + it.effect("narrows through search on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(searchOfCall(0)).toBe("review-requested:bilal sort:updated-desc"); + }), + ); + + it.effect("carries every repository and every qualifier into one search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "pull requests page", + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // One request for both repositories, carrying everything the per-repository read expresses + // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the + // order the page reads in. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + + "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", + ); + }), + ); + + it.effect("narrows a search to the author, and to merged on the merged tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "merged", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual( + searchQueryOfCall(0), + "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", + ); + }), + ); + + it.effect("keeps a searched-for qualifier inside the phrase, and out of argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: 'x" is:merged repo:evil/repo', + }); + + // Quoted and escaped, so the words a reader typed narrow the listing rather than widening + // it — and the whole document travels over stdin rather than in a visible argv. + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', + ); + expect(callAt(0).args).not.toContain("-f"); + }), + ); + + it.effect("refuses to search for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const failure = yield* Effect.flip( + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "acme/web is:merged"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }), + ); + + // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("files each searched row under the repository it came from", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + searchPage([ + searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. + {}, + ]), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.deepStrictEqual( + batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), + [ + ["acme/web", 7, "https://avatars/octocat"], + ["pingdotgg/t3code", 9, "https://avatars/octocat"], + ], + ); + // The listing leaves the line counts to a read of their own. + assert.deepStrictEqual( + batch.items.map((item) => [item.additions, item.deletions]), + [ + [0, 0], + [0, 0], + ], + ); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + searchPage([ + searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), + searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const read = () => + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + const overflowing = yield* read(); + const capped = yield* read(); + + // The extra row is the probe, and it is not handed on. + assert.strictEqual(overflowing.items.length, 2); + assert.isTrue(overflowing.truncated); + // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. + assert.isTrue(capped.truncated); + }), + ); + + it.effect("reads the line counts in chunks, and files them back by position", () => + Effect.gen(function* () { + const changeRequests = Array.from({ length: 26 }, (_, index) => ({ + repository: "acme/web", + number: index + 1, + })); + mockedExecute.mockImplementation(() => + // Every chunk answers for its first alias only, so a row GitHub said nothing about is + // dropped rather than shown as a change of no size. + Effect.succeed( + output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stats = yield* cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests, + }); + + // Twenty-five aliases a request, so twenty-six rows are two requests. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.deepStrictEqual(stats, [ + { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, + { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, + ]); + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain('s0: repository(owner: "acme", name: "web")'); + expect(document).toContain("pullRequest(number: 25)"); + }), + ); + + it.effect("refuses to look up counts for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const failure = yield* Effect.flip( + cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], + }), + ); + + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("hands a search to GitHub rather than to the rows already read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "pull requests page", + }); + + // The recency qualifier rides along, because free text would otherwise reorder the page + // by relevance and truncation would drop the newest matches. + expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc'); + }), + ); + + it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "page", + }); + + // One `--search` is all gh reads, so a second would silently drop the first. + const args = callAt(0).args; + assert.strictEqual(args.filter((arg) => arg === "--search").length, 1); + expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc'); + }), + ); + + it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-- is:merged label:secret "widen me"', + }); + + // Every word stays inside one phrase: nothing before it, nothing after it, and the + // leading dashes are text rather than the start of another argument. + expect(searchOfCall(0)).toBe( + String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`, + ); + expect(callAt(0).args).not.toContain("is:merged"); + }), + ); + + it.effect("escapes a backslash before the quote it would otherwise let out", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: String.raw`a\" is:merged`, + }); + + // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where + // this says it does; escaping the quote alone would have closed it early. + expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`); + }), + ); + + it.effect("asks for nothing but the order when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + // An empty phrase would match nothing rather than everything, so it is left out; the + // order the page reads rows in is asked for whether or not anything was typed. + expect(searchOfCall(0)).toBe("sort:updated-desc"); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop — + // which is what keeps the ones beside them from being skipped. + expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc"); + assert.isTrue(batch.continues); + }), + ); + + it.effect("answers a search that found nothing with nothing, not with the whole repository", () => + Effect.gen(function* () { + // The fallback is for a repository the index does not cover. Under a text search an empty + // answer means the text matched nothing, and listing everything instead would fill the + // page with rows the reader did not search for. + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "fdsfklj", + }); + + assert.strictEqual(batch.items.length, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("reads a repository GitHub will not search the way gh lists one", () => + Effect.gen(function* () { + // GitHub answers for a repository outside its search index with no rows and no error. + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + // The fallback itself uses no search, then narrows the decoded rows locally. They still + // arrive in gh's own order, so nothing can carry on from them. + expect(searchOfCall(1)).toBeUndefined(); + assert.isFalse(batch.continues); + }), + ); + + it.effect("keeps state and involvement filters on the search-free fallback", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(4, 1, (number) => ({ + state: number === 4 ? "OPEN" : "CLOSED", + ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), + reviewRequests: + number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], + })), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + // Individual requests for this viewer and team requests survive. The fallback cannot + // resolve team membership, so dropping team-routed reviews would hide legitimate work. + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + expect(searchOfCall(1)).toBeUndefined(); + assert.isFalse(batch.continues); + }), + ); + + it.effect("grows the search-free fallback until it fills the filtered page", () => + Effect.gen(function* () { + const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(4, 1, (number) => + number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([4]); + const firstFallbackArgs = callAt(1).args; + const secondFallbackArgs = callAt(2).args; + expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3"); + expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6"); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("bounds a sparse search-free fallback and reports the unread tail", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((_input) => { + if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]")); + const args = callAt(mockedExecute.mock.calls.length - 1).args; + const limit = Number(args[args.indexOf("--limit") + 1]); + return Effect.succeed( + output( + pullRequests(limit, 1, () => ({ + reviewRequests: [{ login: "somebody-else" }], + })), + ), + ); + }); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args; + expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000"); + assert.strictEqual(batch.items.length, 0); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("takes an empty slice for a repository that has run out, not one to read again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // A repository that answered the search once answers it again, so an empty slice under a + // cursor is the end of it rather than a repository search cannot reach. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("merges with the strategy it was asked for", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--squash", + ]); + }), + ); + + it.effect("returns a pull request to draft by undoing ready", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "draft", + }); + + // gh has no `draft` command; going back is `ready --undo`. + expect(callAt(0).args).toEqual([ + "pr", + "ready", + "7", + "--repo", + "github.com/acme/web", + "--undo", + ]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.commentOnPullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + body: "Looks good.", + }); + + // argv shows up in process listings and in process-runner failure messages. + expect(callAt(0).args).toEqual([ + "pr", + "comment", + "7", + "--repo", + "github.com/acme/web", + "--body-file", + "-", + ]); + expect(callAt(0).stdin).toBe("Looks good."); + expect(callAt(0).args).not.toContain("Looks good."); + }), + ); + + it.effect("names the host on every repository it addresses", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // A bare `owner/repo` resolves against github.com, which is a different repository. + expect(callAt(0).args).toContain("github.acme.dev/acme/web"); + }), + ); + + it.effect("asks a GitHub Enterprise host for its own review threads", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + const args = callAt(0).args; + expect(args).toContain("--hostname"); + expect(args).toContain("github.acme.dev"); + expect(args).toContain("owner=acme"); + expect(args).toContain("name=web"); + }), + ); + + it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(diff.nextCursor); + assert.isFalse(diff.truncated); + // The common case pays for one request and not the files API on top of it. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => + Effect.gen(function* () { + // GitHub answers 406 rather than a diff past 300 changed files. + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + assert.isFalse(diff.truncated); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(diff.nextCursor); + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); + const args = callAt(1).args; + expect(args).toContain("--hostname"); + expect(args).toContain("github.acme.dev"); + expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1"); + }), + ); + + it.effect("hands back a cursor for the next page rather than walking on by itself", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // A full page means more files, which the reader asks for; it is not a truncated slice. + assert.isFalse(diff.truncated); + assert.isNotNull(diff.nextCursor); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("carries on from a cursor without asking `gh pr diff` again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); + // The second slice is one request: the cursor already says where to read. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from the commit endpoint rather than from `gh pr diff`", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + // One request: the commit's own changes never take the `gh pr diff` road. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNull(diff.nextCursor); + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + const args = callAt(0).args; + expect(args).toContain( + "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", + ); + // The commit endpoint wraps its files in an object, which jq unwraps for the decoder. + expect(args).toContain(".files // []"); + }), + ); + + it.effect("pages inside a commit the way it pages the pull request's own files", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const target = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "../../pulls/8/files", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/root.ts", + newPath: "src/root.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d"); + }), + ); + + it.effect("reports unusable diff revisions as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); + if (error._tag === "GitHubDiffRevisionsUnavailableError") { + assert.strictEqual(error.number, 7); + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("binary\uFFFDcontents", false, true)), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("reports the refused diff when the files API cannot answer either", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error, diffRefused); + }), + ); + + it.effect("skips the avatar lookup when a listing named nobody", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const avatars = yield* cli.listActorAvatars({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + ids: [], + }); + + assert.strictEqual(avatars.size, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("fails when the authenticated account has no login", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); + }), + ); + + it.effect("sends a whole review as one request body over stdin", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + }); + + expect(callAt(0).args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/pulls/7/reviews", + "--input", + "-", + ]); + // One request, so nothing is on the pull request until the verdict is. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ + event: "APPROVE", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], + }); + }), + ); + + it.effect("sends a reply body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.replyToReviewThread({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + threadId: "PRRT_1", + body: "Fixed in 42ff8ec.", + }); + + // A reply is the reader's own words, so it travels the same way a comment body does. + expect(callAt(0).args).toEqual([ + "api", + "graphql", + "--hostname", + "github.com", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(0).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addPullRequestReviewThreadReply"); + expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." }); + expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec."); + }), + ); + + it.effect("resolves and unresolves through the mutation each one needs", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: true, + }); + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: false, + }); + + const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string }; + expect(parse(0).query).toContain("resolveReviewThread("); + expect(parse(1).query).toContain("unresolveReviewThread("); + // A GitHub Enterprise thread is resolved on its own host, not on github.com. + expect(callAt(0).args).toContain("github.acme.dev"); + }), + ); + + it.effect("fails the read when gh returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + }), + ); + + it.effect("fails a files page too large to read rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + // What matters is that it fails at all: an empty patch with no cursor would render as a + // change with no files and report the rest of it as already read. The refusal that sent + // the read down this road is the one reported, by design. + assert.strictEqual(error._tag, "GitHubCliCommandError"); + }), + ); + + it.effect("pages an oversized patch by file rather than handing back a severed one", () => + Effect.gen(function* () { + // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file. + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const slice = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The severed patch is thrown away; what comes back is assembled from whole files. + expect(callAt(1).args.join(" ")).toContain("/pulls/7/files"); + expect(slice.patch).toContain("src/file1.ts"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("follows the cursor to the review threads the first page left behind", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The first page asks from the beginning, which gh only sends as a typed JSON null. + expect(callAt(0).args).toContain("cursor=null"); + expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE"); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); + assert.isFalse(conversation.truncated); + }), + ); + + it.effect("stops at the thread bound and says the conversation was cut short", () => + Effect.gen(function* () { + // A host that never runs out of pages: the walk has to end itself. + mockedExecute.mockReturnValue( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(conversation.truncated); + }), + ); + + it.effect("finishes a thread longer than one page from the thread's own node", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + reviewThreadsPage( + [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], + null, + ), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(callAt(1).args).toContain("threadId=PRRT_1"); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2", "c3"]); + // GitHub's own count, which is what the page shows however much of it was read. + assert.strictEqual(conversation.commentCount, 3); + }), + ); + + it.effect( + "asks for the reader's standing on the repository and on the pull request at once", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const access = yield* cli.getViewerAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // One request, because both answers hang off the same repository object. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain("number=7"); + expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + }), + ); + + it.effect("reads the viewer's role off the same call as the merge settings", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerPermission: "WRITE", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const access = yield* cli.getRepositoryAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain( + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission", + ); + assert.isTrue(access.canWrite); + expect(access.mergeCapabilities).toEqual({ merge: false, squash: true, rebase: true }); + }), + ); + + it.effect("asks GitHub to review, naming the collection a request is added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + ], + requested: true, + }); + + const call = callAt(0); + expect(call.args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/pulls/7/requested_reviewers", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.stdin ?? "")).toEqual({ + reviewers: ["octocat"], + team_reviewers: ["reviewers"], + }); + }), + ); + + it.effect("takes a request back by deleting from the same collection it was added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [{ id: "octocat", kind: "user" }], + requested: false, + }); + + const call = callAt(0); + expect(call.args).toContain("DELETE"); + expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.stdin ?? "")).toEqual({ + reviewers: ["octocat"], + team_reviewers: [], + }); + }), + ); + + it.effect("reads who may review and who already has in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], + }, + pullRequest: { + author: { login: "bilal" }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The people, who has been asked and who opened the pull request all hang off the same + // repository object, so the menu costs one request. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain("number=7"); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts new file mode 100644 index 00000000000..84aae08aa01 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -0,0 +1,1439 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestActor, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeMethod, + PullRequestReviewCommentDraft, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestThreadComment, +} from "@t3tools/contracts"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + ACTOR_AVATARS_GRAPHQL_QUERY, + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodeActorAvatarsJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodePullRequestSearchJson, + decodePullRequestStatsJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + buildPullRequestStatsGraphQlQuery, + encodeGraphQlRequestJson, + pullRequestSearchGraphQlQuery, + PULL_REQUEST_SEARCH_MAX_ROWS, + PULL_REQUEST_DETAIL_JSON_FIELDS, + PULL_REQUEST_LIST_JSON_FIELDS, + REPOSITORY_ACCESS_JSON_FIELDS, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEW_THREADS_GRAPHQL_QUERY, + reviewThreadConversation, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decodeViewerPermissionsJson, + type GitHubPullRequestDetail, + type GitHubPullRequestListItem, + type GitHubPullRequestSearchItem, + type GitHubReviewThreadComments, + type GitHubRepositoryAccess, + type GitHubReviewThreadEntry, + type GitHubReviewThreadPage, + type GitHubViewerAccess, +} from "./gitHubPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitHubPullRequestReadError extends Schema.TaggedErrorClass()( + "GitHubPullRequestReadError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitHub CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: gh answered, the account it answered for just has no login. */ +export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass()( + "GitHubViewerLoginUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitHub CLI returned no login for the authenticated account."; + } + + override get message(): string { + return `GitHub CLI failed in getViewerLogin: ${this.detail}`; + } +} + +/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ +export class GitHubDiffCursorError extends Schema.TaggedErrorClass()( + "GitHubDiffCursorError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The diff cursor was not one this pull request handed out."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class GitHubDiffCommitError extends Schema.TaggedErrorClass()( + "GitHubDiffCommitError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; + } +} + +/** The revisions read successfully, but cannot name both sides this file needs. */ +export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedErrorClass()( + "GitHubDiffRevisionsUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + number: Schema.Int, + commit: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return this.commit === undefined + ? `Pull request #${this.number} reported no usable base and head revisions.` + : `Commit ${this.commit} reported no usable revisions for this file.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; + } +} + +/** A blob exists, but expanding it would be unsafe or would not produce text. */ +export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedErrorClass()( + "GitHubDiffFileContentsUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + path: Schema.String, + reason: Schema.Literals(["oversized", "binary"]), + }, +) { + get detail(): string { + return this.reason === "oversized" + ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` + : `The diff file '${this.path}' is binary.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; + } +} + +/** + * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL + * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a + * name that is not one is refused here rather than escaped into something GitHub might read as a + * qualifier of its own. + */ +export class GitHubRepositorySelectorError extends Schema.TaggedErrorClass()( + "GitHubRepositorySelectorError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + }, +) { + get detail(): string { + return "A repository was named that GitHub cannot address."; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export type GitHubPullRequestCliError = + | GitHubCli.GitHubCliError + | GitHubPullRequestReadError + | GitHubDiffCursorError + | GitHubDiffCommitError + | GitHubDiffRevisionsUnavailableError + | GitHubDiffFileContentsUnavailableError + | GitHubRepositorySelectorError + | GitHubViewerLoginUnavailableError; + +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; +/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ +const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; + +/** What the files API serves at most in one response, which is what one slice is made of. */ +const DIFF_FILES_PAGE_SIZE = 100; + +/** + * Pages of review threads to follow before the conversation is reported as truncated. GitHub + * serves a hundred threads a page, so this is a thousand threads — past anything a pull request + * a person is reading has, and short of walking a repository-sized conversation forever. + */ +const REVIEW_THREAD_PAGES = 10; + +/** + * And pages of one thread's own comments, for the rare thread longer than a single page. A + * thousand replies under one line is already a conversation nobody finishes reading. + */ +const REVIEW_THREAD_COMMENT_PAGES = 10; + +/** How many over-long threads are finished at once, so a wide conversation is not read serially. */ +const REVIEW_THREAD_CONCURRENCY = 4; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ + readonly continues: boolean; +} + +export interface GitHubPullRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +/** + * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request + * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. + */ +const STAT_ALIASES_PER_REQUEST = 25; +const STAT_REQUEST_CONCURRENCY = 4; + +export interface GitHubPullRequestSearchBatch { + /** Rows across every repository asked for, newest update first, each naming its own. */ + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export interface GitHubPullRequestDiffSlice { + readonly patch: string; + /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + readonly truncated: boolean; + /** Where the next slice starts, or null once the patch is whole. */ + readonly nextCursor: string | null; +} + +export class GitHubPullRequestCli extends Context.Service< + GitHubPullRequestCli, + { + readonly getViewerLogin: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text for `--search`, matched as one literal phrase. */ + readonly query?: string | undefined; + /** Where to carry on from, as a `updated:` qualifier on the same search. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + /** + * The same listing for a whole host in one search. `limit` is the size of the slice across + * all of the repositories rather than per repository, because that is what a search answers: + * the newest rows of the lot, which is exactly the page. + */ + readonly searchPullRequests: (input: { + /** Any checkout on the host; the search names its repositories itself. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + /** The line counts the search leaves out, for rows already on the page. */ + readonly listPullRequestStats: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, GitHubPullRequestCliError>; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect; + + readonly getPullRequestDiffFileContents: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }) => Effect.Effect< + { readonly oldContents: string; readonly newContents: string }, + GitHubPullRequestCliError + >; + + readonly listReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ + readonly listActorAvatars: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly ids: ReadonlyArray; + }) => Effect.Effect, GitHubPullRequestCliError>; + + /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */ + readonly getRepositoryAccess: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + }) => Effect.Effect; + + /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ + readonly getViewerAccess: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** Who this pull request may be sent to, and who it has already been sent to. */ + readonly listReviewerCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + /** False deletes the same collection a request posts to, which takes the request back. */ + readonly requested: boolean; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly commentOnPullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToReviewThread: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setReviewThreadResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly resolved: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitHubPullRequestCli") {} + +/** + * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. + * The host is not read off the identity: it travels alongside it, because the identity a + * project records is the path below its host and never names the host itself. + */ +export function parseRepositorySelector(value: string): { + readonly owner: string; + readonly name: string; +} { + const parts = value.trim().split("/").filter(Boolean); + return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a request path, so it is parsed + * rather than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole + * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so + * text typed into a search box could widen the very listing it is meant to narrow — inside them + * it is only text. The two characters that could end the phrase early are therefore escaped + * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which + * would let everything after it out of the phrase. + * + * The phrase is one argv element, so nothing in it can become a flag of its own either. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function involvementArgs(input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + /** Where to carry on from, which only a search can express. */ + readonly cursor?: ProviderListCursor | undefined; + /** + * Ask GitHub for the order the page reads its rows in. False on the fallback read, which + * cannot use search at all and takes whatever order `gh pr list` answers in. + */ + readonly sorted: boolean; +}): ReadonlyArray { + // `--state closed` includes merged pull requests, so the Closed tab additionally excludes + // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh` + // takes one `--search`, so the reader's text joins the qualifiers rather than replacing them. + const query = input.query?.trim() ?? ""; + // The fallback read exists because this repository's search index answered nothing, so it goes + // nowhere near search: no order, cursor or qualifiers. Its decoded rows are narrowed by state + // and involvement below, since widening either would put unrelated pull requests on the page. + const searchTerms = !input.sorted + ? [] + : [ + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(input.state === "closed" ? ["is:unmerged"] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // The instant the last slice ended on, and everything before it. Inclusive, because rows + // sharing one instant are ordinary and the caller drops the ones it has already sent — + // asking for strictly older would lose the rest of them instead. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + // `gh pr list` answers newest-created first, which is not the order the page reads rows in + // and not an order a continuation can carry on from: a change request opened last year and + // touched this morning belongs at the top of the list and at the front of the first slice. + // Free text would otherwise come back in best-match order, which is worse again. + "sort:updated-desc", + ]; + return [ + ...(input.involvement === "authored" ? ["--author", input.viewer] : []), + ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), + ]; +} + +/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ +function matchesUnsortedListing( + item: GitHubPullRequestListItem, + input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + }, +): boolean { + const matchesState = input.state === "all" || item.state === input.state; + const viewer = input.viewer.toLowerCase(); + const matchesInvolvement = + input.involvement === "all" || + (input.involvement === "authored" + ? item.author?.login.toLowerCase() === viewer + : item.hasTeamReviewRequest || + item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); + return matchesState && matchesInvolvement; +} + +/** What a repository selector may hold before it goes into a search as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** + * The same listing as one GitHub search across several repositories, which is the only way to + * read a whole host in one request. + * + * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead, + * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is + * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed + * is:unmerged`. The two belong together; a tab added to one wants adding to the other. + * + * Null where a repository is not `owner/name`. A name is written into the query as itself, and a + * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its + * own — so an unaddressable one refuses the whole read rather than being escaped into something + * GitHub might still read. + */ +function searchQuery(input: { + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; +}): string | null { + if (input.repositories.length === 0) return null; + const repositories = input.repositories.map((repository) => repository.trim()); + if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; + const query = input.query?.trim() ?? ""; + return [ + "is:pr", + // "all" is every state, which `is:pr` already is. + ...(input.state === "open" ? ["is:open"] : []), + ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), + ...(input.state === "merged" ? ["is:merged"] : []), + ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + // The order the page reads its rows in, and the only order a continuation can carry on from. + "sort:updated-desc", + ...repositories.map((repository) => `repo:${repository}`), + ].join(" "); +} + +/** + * The `after` a paged read carries. gh sends a JSON null only through a typed field, and an + * untyped `cursor=` would send the empty string, which GitHub refuses as a cursor rather than + * reading as "start at the beginning". + */ +function cursorVariable(cursor: string | null): readonly [string, string] { + return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["merge", `--${mergeMethod ?? "merge"}`]; + case "ready": + return ["ready"]; + case "draft": + return ["ready", "--undo"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is + // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own + // install rather than to a same-named repository on github.com. + const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [ + "--repo", + `${input.host}/${input.repository}`, + ]; + + /** + * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error, + * so a failed mutation is already a failed command rather than a body to inspect. + * + * The query and its variables travel over stdin as one document: a variable can carry a + * body the reader wrote, and argv is visible in process listings and echoed back inside + * process-runner failure messages. + */ + const graphql = (input: { + readonly cwd: string; + readonly host: string; + readonly query: string; + readonly variables: Readonly>; + }) => + github + .execute({ + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), + }) + .pipe(Effect.asVoid); + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + /** Variables as `-f` flags, for values this module composed itself. */ + readonly variables?: ReadonlyArray; + /** + * Variables carrying words the reader typed. Document and variables travel over stdin + * together, because argv is visible in process listings and is echoed back inside a + * process-runner failure message. + */ + readonly privateVariables?: Readonly>; + readonly query: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + github + .execute( + input.privateVariables === undefined + ? { + cwd: input.cwd, + args: [ + "api", + "graphql", + "--hostname", + input.host, + ...(input.variables ?? []).flat(), + "-f", + `query=${input.query}`, + ], + } + : { + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ + query: input.query, + variables: input.privateVariables, + }), + }, + ) + .pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300 + * changed files, and still serves those files' hunks here. + * + * A page is a whole number of files, so each one parses on its own; the caller carries on from + * `nextCursor` for as long as GitHub keeps handing pages back. + * + * A named commit is read from the commit endpoint, which lists the same file entries and pages + * them the same way — only wrapped in an object, which jq unwraps before they are decoded. + */ + const diffFilesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` + : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, + // An empty commit carries no `files` at all, which is a commit with nothing in it + // rather than an answer that could not be read. + ...(input.commit === undefined ? [] : ["--jq", ".files // []"]), + ], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => { + // Checked before decoding: a byte-truncated response is a JSON prefix, which would + // fail to parse. Nothing of this page can be shown, and an empty patch would render + // as a change with no files rather than as the failure it is; slices already handed + // over stay with the reader either way. + if (result.stdoutTruncated) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDiff", + cause: new Error(`Page ${input.page} of the changed files was too large to read.`), + }), + ); + } + const decoded = decodePullRequestFilesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDiff", + cause: decoded.failure, + }), + ); + } + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE; + return Effect.succeed({ + patch: decoded.success.patch, + truncated: decoded.success.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + }); + }), + ); + }; + + const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = + (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); + } + const { owner, name } = parseRepositorySelector(input.repository); + const refsResult = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + "--jq", + input.commit === undefined + ? "[.base.sha, .head.sha] | @tsv" + : "[.parents[0].sha, .sha] | @tsv", + ], + maxOutputBytes: 1024, + timeoutMs: DIFF_TIMEOUT_MS, + }); + // Keep a leading tab: a root commit has no parent, and jq represents that absent old + // revision as the empty field before the tab. Every file in it is new, so that is a + // usable answer whenever the caller does not need the old side. + const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t"); + const rootCommitNewFile = + input.commit !== undefined && input.changeType === "new" && baseRef === ""; + if ( + refsResult.stdoutTruncated || + !headRef || + extraRefs.length > 0 || + (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || + !isCommitSha(headRef) + ) { + return yield* new GitHubDiffRevisionsUnavailableError({ + command: "gh", + cwd: input.cwd, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + } + + const readFile = (revision: string, filePath: string) => + github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--header", + "Accept: application/vnd.github.raw+json", + `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + ], + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitHubDiffFileContentsUnavailableError({ + command: "gh", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath), + input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + + return GitHubPullRequestCli.of({ + getViewerLogin: (input) => + github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( + Effect.flatMap((result) => { + const login = result.stdout.trim(); + return login.length > 0 + ? Effect.succeed(login) + : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd })); + }), + ), + + listPullRequests: (input) => { + const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); + const read = ( + continues: boolean, + requestedRows = input.limit + 1, + ): Effect.Effect => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input), + ...involvementArgs({ ...input, sorted: continues }), + "--state", + input.state, + "--limit", + // One extra row reveals that the repository has more than the page shows. + String(requestedRows), + "--json", + PULL_REQUEST_LIST_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ items: [], truncated: false, continues }); + } + const decoded = decodePullRequestListJson(raw); + if (Result.isSuccess(decoded)) { + const items = continues + ? decoded.success.items + : decoded.success.items.filter((item) => matchesUnsortedListing(item, input)); + if ( + !continues && + items.length < input.limit && + decoded.success.rawCount >= requestedRows && + requestedRows < fallbackMaxRows + ) { + const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); + if (nextRows > requestedRows) return read(false, nextRows); + } + return Effect.succeed({ + items: items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: continues + ? decoded.success.rawCount > input.limit + : items.length > input.limit || decoded.success.rawCount >= requestedRows, + continues, + }); + } + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + }), + ); + // GitHub does not index every repository for search, and one it will not search answers + // with no rows rather than with an error — so an empty listing is read again the way `gh` + // lists without one. Those rows come back newest-created first, an order no `updated:` + // qualifier can carry on from, so that page says it cannot be continued and the reader + // reaches the rest of it by asking for a larger page, as every listing used to. + // + // Only ever the first slice: a repository that answered the search once will answer it + // again, so an empty slice under a cursor is a repository that has run out. + // A text search that finds nothing has found nothing: falling back would answer it with the + // repository's whole list, which is every row the reader did not search for. The fallback + // is for a repository the index does not cover, and a listing with no text to match is the + // only place an empty answer can mean that. + const searched = (input.query?.trim().length ?? 0) > 0; + return read(true).pipe( + Effect.flatMap((batch) => + batch.items.length === 0 && input.cursor === undefined && !searched + ? read(false) + : Effect.succeed(batch), + ), + ); + }, + + searchPullRequests: (input) => { + const query = searchQuery(input); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + command: "gh", + cwd: input.cwd, + operation: "searchPullRequests", + }), + ); + } + // One extra row reveals that the host has more than the slice shows, the way the + // per-repository read does — up to GitHub's own ceiling on a search page, past which + // `hasNextPage` is what says there is more. + const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "searchPullRequests", + // The reader's own words are in the query, so it travels over stdin rather than in argv. + privateVariables: { q: query }, + query: pullRequestSearchGraphQlQuery(rows), + decode: decodePullRequestSearchJson, + }).pipe( + Effect.map((batch) => ({ + items: batch.items.slice(0, input.limit), + truncated: batch.rawCount > input.limit || batch.hasNextPage, + })), + ); + }, + + listPullRequestStats: (input) => { + const chunks: Array> = + []; + for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStatsGraphQlQuery(chunk); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequestStats", + }), + ); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStats", + query, + decode: decodePullRequestStatsJson, + }).pipe( + Effect.map((stats) => + chunk.flatMap((changeRequest, index) => { + const stat = stats.get(index); + return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; + }), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((results) => results.flat())); + }, + + getPullRequestDetail: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: decoded.failure, + }), + ); + }), + ), + + getPullRequestDiff: (input) => { + const filesPage = (page: number) => + diffFilesPage({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + page, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); + } + // A cursor only ever comes from the files walk, so a reader carrying one is already past + // the point where `gh pr diff` had anything to say. + if (input.cursor !== undefined) { + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) + : filesPage(page); + } + // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. + if (input.commit !== undefined) { + return filesPage(1); + } + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "diff", + String(input.number), + ...repositoryArgs(input), + "--color", + "never", + "--patch", + ], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => + // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor + // something the reader can carry on from. The files API can serve the same change a + // whole number of files at a time, so an oversized patch takes that road as well. + result.stdoutTruncated + ? filesPage(1) + : // One read served the whole patch, so there is no next slice to ask for. + Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }), + ), + // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read + // from the files API instead, a page per call. Only once the direct read has failed: a + // pull request GitHub will serve a diff for must not pay for a second request. A + // fallback that fails too reports the original refusal, which is the one that explains + // the page. Narrowed to a command that ran and was refused: a missing `gh` or a + // signed-out one fails the same way for every request. + Effect.catchTags({ + GitHubCliCommandError: (error) => + filesPage(1).pipe(Effect.catch(() => Effect.fail(error))), + }), + ); + }, + + getPullRequestDiffFileContents, + + listReviewThreadComments: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const threadPage = ( + cursor: string | null, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + cursorVariable(cursor), + ], + query: REVIEW_THREADS_GRAPHQL_QUERY, + decode: decodeReviewThreadsJson, + }); + const commentPage = ( + threadId: string, + cursor: string, + ): Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + }, + GitHubPullRequestCliError + > => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [["-f", `threadId=${threadId}`], cursorVariable(cursor)], + query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + decode: decodeReviewThreadCommentsJson, + }); + + const entries: GitHubReviewThreadEntry[] = []; + const avatarsByLogin = new Map(); + const commitStats = new Map< + string, + { readonly additions: number; readonly deletions: number } + >(); + let reviewers: ReadonlyArray = []; + let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + let cursor: string | null = null; + let page = 0; + do { + const read: GitHubReviewThreadPage = yield* threadPage(cursor); + entries.push(...read.threads); + for (const [login, avatarUrl] of read.avatarsByLogin) + avatarsByLogin.set(login, avatarUrl); + // The roster and the viewer's standing travel with every page, and the first one + // already carries all of both. + if (page === 0) { + reviewers = read.reviewers; + viewer = read.viewer; + for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); + } + cursor = read.nextCursor; + page += 1; + } while (cursor !== null && page < REVIEW_THREAD_PAGES); + + // Only the threads GitHub said were unfinished cost a request; the rest arrived whole + // with the page they were listed on. + const finished = yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const comments = [...entry.thread.comments]; + let commentCursor = entry.nextCommentCursor; + let commentPageCount = 0; + while (commentCursor !== null && commentPageCount < REVIEW_THREAD_COMMENT_PAGES) { + const read = yield* commentPage(entry.thread.id, commentCursor); + comments.push(...read.comments); + commentCursor = read.nextCursor; + commentPageCount += 1; + } + return { + thread: { ...entry.thread, comments }, + commentCount: entry.commentCount, + truncated: commentCursor !== null, + }; + }), + { concurrency: REVIEW_THREAD_CONCURRENCY }, + ); + + const reviewThreads = finished.map((entry) => entry.thread); + return { + comments: reviewThreadConversation(reviewThreads), + reviewThreads, + // GitHub's own count of each thread, so the number the page shows is the host's even + // where a bound kept some of the words on GitHub. + commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0), + truncated: cursor !== null || finished.some((entry) => entry.truncated), + reviewers, + avatarsByLogin, + commitStats, + viewer, + }; + }), + + listActorAvatars: (input) => { + if (input.ids.length === 0) { + return Effect.succeed(new Map()); + } + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + "--hostname", + input.host, + ...input.ids.flatMap((id) => ["-f", `ids[]=${id}`]), + "-f", + `query=${ACTOR_AVATARS_GRAPHQL_QUERY}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeActorAvatarsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listActorAvatars", + cause: decoded.failure, + }), + ); + }), + ); + }, + + getRepositoryAccess: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + `${input.host}/${input.repository}`, + "--json", + REPOSITORY_ACCESS_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeRepositoryAccessJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getRepositoryAccess", + cause: decoded.failure, + }), + ); + }), + ), + + getViewerAccess: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getViewerAccess", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decode: decodeViewerPermissionsJson, + }); + }, + + listReviewerCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewerCandidates", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, + decode: decodeReviewerCandidatesJson, + }); + }, + + setReviewerRequest: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + // Posting to a login GitHub has already been asked about is what a re-request is, so + // there is nothing to say here about somebody who has reviewed once already. The body + // travels over stdin for the reason every other one does: argv is visible in process + // listings and echoed back inside process-runner failure messages. + args: [ + "api", + "--method", + input.requested ? "POST" : "DELETE", + "--hostname", + input.host, + `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + "--input", + "-", + ], + stdin: buildReviewerRequestJson(input.reviewers), + }) + .pipe(Effect.asVoid); + }, + + runPullRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return github + .execute({ + cwd: input.cwd, + args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags], + }) + .pipe(Effect.asVoid); + }, + + commentOnPullRequest: (input) => + github + .execute({ + cwd: input.cwd, + // The body travels over stdin: argv is visible in process listings and is echoed + // back inside process-runner failure messages. + args: [ + "pr", + "comment", + String(input.number), + ...repositoryArgs(input), + "--body-file", + "-", + ], + stdin: input.body, + }) + .pipe(Effect.asVoid), + + submitReview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + // The whole review is one request, so nothing is visible to anyone else until the + // verdict is sent. The payload travels over stdin for the same reason a comment + // body does: argv is visible in process listings and echoed back in failures. + args: [ + "api", + "--method", + "POST", + "--hostname", + input.host, + `repos/${owner}/${name}/pulls/${input.number}/reviews`, + "--input", + "-", + ], + stdin: buildReviewSubmissionJson({ + verdict: input.verdict, + body: input.body, + comments: input.comments, + }), + }) + .pipe(Effect.asVoid); + }, + + replyToReviewThread: (input) => + graphql({ + cwd: input.cwd, + host: input.host, + query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + variables: { threadId: input.threadId, body: input.body }, + }), + + setReviewThreadResolution: (input) => + graphql({ + cwd: input.cwd, + host: input.host, + query: input.resolved + ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION + : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + variables: { threadId: input.threadId }, + }), + }); +}); + +export const layer = Layer.effect(GitHubPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts new file mode 100644 index 00000000000..0abd8ef56b7 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; + +describe("gitHubViewerPermissions", () => { + it("offers everything to a viewer who can write to the repository", () => { + expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }); + }); + + it("leaves a passer-by on a repository they can only read nothing but the review", () => { + // Every open-source pull request somebody else opened: GitHub says no to all five actions + // and to resolving, and yes to commenting and to every verdict. + expect( + gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }), + ).toEqual({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + // Asking somebody else to review is the one thing read access never stretches to. + requestReviewers: false, + }); + }); + + it("keeps an author's own pull request theirs to close, with read access and no more", () => { + expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({ + // Merging is the one thing writing is needed for; the rest an author may do. + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + // GitHub refuses an author's approval of their own change, so the page does not offer one. + verdicts: ["comment"], + requestReviewers: false, + }); + }); + + it.effect("does not assume authorship when the review-thread read fails", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.viewerPermissions).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => + Effect.succeed({ + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], + }), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + listReviewThreadComments: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "listReviewThreadComments", + cause: new Error("transient GraphQL failure"), + }), + ), + }), + ), + ), + ); +}); + +describe("loginAvatarUrl", () => { + it("serves a user's picture from the host they belong to", () => { + expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); + expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( + "https://ghe.example.com/octocat.png?size=80", + ); + }); + + it("has nothing for an app, which names no page", () => { + // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is + // worse than the initials it would replace. + expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); + }); + + it("refuses anything that is not a login, rather than building a URL out of it", () => { + for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { + expect(loginAvatarUrl(login, "github.com")).toBeNull(); + } + }); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts new file mode 100644 index 00000000000..22af8edff40 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -0,0 +1,335 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestActor, + PullRequestCapabilities, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; + +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["merge", "ready", "draft", "close", "reopen"], + mergeMethods: ["merge", "squash", "rebase"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, +}; + +/** + * What the signed-in account may do here, from the three things GitHub says about it. + * + * Merging needs a role that can push, which is the one thing a stranger on an open-source + * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a + * pull request may close it, reopen it and move it in and out of draft with no more than read + * access on the repository it was opened against. + * + * Commenting and reviewing are not gated at all: read access is enough to say something and + * enough to approve or ask for changes, which is what open-source review consists of. Resolving a + * conversation is the exception — GitHub allows it to whoever can write, and to the author of the + * pull request the conversation is on. + * + * Asking somebody else for a review needs write access, which is the one thing here an author + * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control + * and refuses the request behind it. + */ +export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { + return { + actions: [ + ...(access.canWrite ? (["merge"] as const) : []), + ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), + ], + comment: true, + resolve: access.canWrite || access.didAuthor, + // Anyone may review a pull request they can see, except their own: GitHub refuses an author's + // approval and their request for changes ("Can not approve your own pull request"), and + // leaves them commenting, which is what an author has to say about their own change anyway. + verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, + requestReviewers: access.canWrite, + }; +} + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitHubPullRequestCli.GitHubPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitHubCliUnavailableError") return "missing-tool"; + if (error._tag === "GitHubCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +/** + * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are + * applied here by login. An actor already carrying one keeps it. + * + * A login GitHub did not answer for falls back to the picture every GitHub install serves at + * `/.png`. The lookup is one more request per repository and can be refused — a rate + * limit, a slow host — and a face that comes and goes between two loads of the same page reads + * as a bug in the page rather than as a request that failed quietly. + */ +function withAvatar( + actor: PullRequestActor | null, + avatarsByLogin: ReadonlyMap, + host: string, +): PullRequestActor | null { + if (actor === null || actor.avatarUrl !== null) return actor; + const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); + return avatarUrl === null ? actor : { ...actor, avatarUrl }; +} + +/** + * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which + * names no page, and a guessed URL that 404s is worse than the initials it would replace. + */ +export function loginAvatarUrl(login: string, host: string): string | null { + return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + new PullRequestProviderError({ + provider: "github", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "github", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.flatMap((page) => + cli + .listActorAvatars({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], + }) + // A listing without faces is still a listing, so a failed lookup falls back to + // the initials rather than taking the rows down with it. + .pipe( + Effect.orElseSucceed(() => new Map()), + Effect.map((avatarsByLogin) => ({ + ...page, + items: page.items.map((item) => ({ + ...item, + author: withAvatar(item.author, avatarsByLogin, input.host), + })), + })), + ), + ), + ), + + /** + * The same listing for a whole host in one search. The avatar lookup the per-repository read + * needs is not here: a search reports an author's picture itself, so a face costs no request + * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. + */ + listChangeRequestsAcross: (input) => + cli + .searchPullRequests({ + cwd: input.cwd, + host: input.host, + repositories: input.repositories, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequestsAcross")), + Effect.map((batch) => ({ + truncated: batch.truncated, + items: batch.items.map((item) => ({ + ...item, + author: withAvatar(item.author, new Map(), input.host), + })), + })), + ), + + listChangeRequestStats: (input) => + cli + .listPullRequestStats({ + cwd: input.cwd, + host: input.host, + changeRequests: input.changeRequests, + }) + .pipe(Effect.mapError(fail("listChangeRequestStats"))), + + getChangeRequest: (input) => + Effect.all( + [ + cli.getPullRequestDetail(input), + cli.getRepositoryAccess({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + }), + // Line comments live on review threads, which `gh pr view --json` cannot reach. A + // GraphQL hiccup must not blank the whole detail, so it degrades to "none" — marked + // truncated, because an unread thread is a missing comment, not an absent one. + cli.listReviewThreadComments(input).pipe( + Effect.orElseSucceed(() => ({ + comments: [], + reviewThreads: [], + commentCount: 0, + truncated: true, + reviewers: [], + avatarsByLogin: new Map(), + commitStats: new Map< + string, + { readonly additions: number; readonly deletions: number } + >(), + // A read that never happened says nothing about the reader, and an unknown + // permission is granted: the controls stay live and GitHub explains any refusal. + viewer: { canUpdate: true, didAuthor: false }, + })), + ), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([pullRequest, repository, reviewThreads]): ProviderChangeRequestDetail => ({ + ...pullRequest, + author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), + commits: pullRequest.commits.map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, + ), + })), + // From the review itself rather than from the listing's outstanding requests, which + // hold no avatar and drop anyone who has already reviewed. + reviewers: reviewThreads.reviewers, + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + })), + })), + mergeCapabilities: repository.mergeCapabilities, + // Both reads were being made anyway: `gh repo view` for the merge settings, and the + // GraphQL conversation read for the pull request's own viewer fields. + viewerPermissions: gitHubViewerPermissions({ + canWrite: repository.canWrite, + ...reviewThreads.viewer, + }), + }), + ), + ), + + getViewerPermissions: (input) => + cli + .getViewerAccess(input) + .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitHubViewerPermissions)), + + getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + getDiffFileContents: (input) => + cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + + listReviewerCandidates: (input) => + cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToReviewThread({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setThreadResolution: (input) => + cli + .setReviewThreadResolution({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts new file mode 100644 index 00000000000..9bfad2648c1 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -0,0 +1,1144 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitLabPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated, + stderrTruncated: false, + stdoutInvalidUtf8, + }; +} + +function mergeRequests(count: number, firstNumber: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + iid: firstNumber + index, + title: `Merge request ${firstNumber + index}`, + web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + })), + ); +} + +/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ +function diffPage(firstIndex: number, count = 100): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + old_path: `src/${firstIndex + index}.ts`, + new_path: `src/${firstIndex + index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ); +} + +/** A page of merge request notes, which is what the flat conversation is read from. */ +function notes(count: number, firstId: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + id: firstId + index, + body: `note ${firstId + index}`, + author: { username: "bilal" }, + created_at: "2026-07-01T00:00:00Z", + })), + ); +} + +/** Who opened the merge request, and somebody already reviewing it. */ +const author = { id: 1, username: "bilal" }; +const reviewer = { id: 5, username: "octocat" }; + +/** One merge request as `/merge_requests/:iid` answers with it. */ +function mergeRequestJson(overrides: Record): string { + return JSON.stringify({ + iid: 7, + title: "Merge request 7", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + author, + ...overrides, + }); +} + +/** The endpoint or subcommand of the nth glab invocation. */ +function argsOfCall(index: number): ReadonlyArray { + return callAt(index).args; +} + +/** The whole nth invocation, so a request body can be asserted alongside its path. */ +function callAt(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitLabPullRequestCli.layer", (it) => { + it.effect("asks GitLab for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 3); + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("projects/acme%2Fweb/merge_requests"); + expect(path).toContain("per_page=11"); + expect(path).toContain("state=opened"); + }), + ); + + it.effect("walks pages at a fixed size, because GitLab pages by offset", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 150); + assert.isTrue(batch.truncated); + for (const index of [0, 1]) { + expect(argsOfCall(index)[1]).toContain("per_page=100"); + } + expect(argsOfCall(0)[1]).toContain("page=1"); + expect(argsOfCall(1)[1]).toContain("page=2"); + }), + ); + + it.effect("hands a search to GitLab's own search parameter", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "page", + }); + + // GitLab matches `search` against title and description, which is more than the row shows. + expect(argsOfCall(0)[1]).toContain("search=page"); + }), + ); + + it.effect("carries on from the number of rows already delivered", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a + // boundary shared by more rows than one page can hold. + const path = argsOfCall(0)[1] ?? ""; + expect(path).not.toContain("updated_before="); + expect(path).toContain("order_by=updated_at"); + expect(path).toContain("per_page=11"); + expect(path).toContain("page=1"); + }), + ); + + it.effect("advances beyond several pages sharing the cursor timestamp", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, + }); + + expect(argsOfCall(0)[1]).toContain("per_page=11"); + expect(argsOfCall(0)[1]).toContain("page=14"); + expect(argsOfCall(1)[1]).toContain("page=15"); + expect(batch.items.map((item) => item.number)).toEqual([ + 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, + ]); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("advances the cursor through malformed raw rows", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray; + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.strictEqual(batch.cursorAdvance, 3); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-a&per_page=1 "b"', + }); + + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); + // The page size the walk fixed is still the only one in the query. + assert.strictEqual(path.match(/per_page=/g)?.length, 1); + }), + ); + + it.effect("asks for no search at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + expect(argsOfCall(0)[1]).not.toContain("search="); + }), + ); + + it.effect("stops walking on a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 40); + assert.isFalse(batch.truncated); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("stops walking when every row on a page fails to decode", () => + Effect.gen(function* () { + // Full pages of unusable rows: nothing is collected, so the collected-count bound never + // trips and only the page bound can end the walk. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); + mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 0); + // ceil((150 + 1) / 100) pages, not one request per page forever. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("asks GitLab for every state on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "all", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("state=all"); + }), + ); + + it.effect("filters by the reviewer when the viewer is reviewing", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); + }), + ); + + it.effect("addresses a nested group project by its encoded full path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/platform/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); + }), + ); + + it.effect("merges immediately rather than leaving auto-merge armed", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=false", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("moves a merge request back to draft through glab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "draft", + }); + + expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.commentOnMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + body: "true", + }); + + const call = mockedExecute.mock.calls[0]; + assert.isDefined(call); + expect(call[0].args).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes", + "--method", + "POST", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a comment reading as a literal `true` stays text. + expect(call[0].stdin).toBe('{"body":"true"}'); + }), + ); + + it.effect("reads one diff page and hands back the cursor for the next", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + // One page per call: the reader asks for the rest, the walk does not run on by itself. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNotNull(diff.nextCursor); + // A full page means more files, not a slice with something missing from it. + assert.isFalse(diff.truncated); + expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); + }), + ); + + it.effect("carries on from a cursor at the page it names", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { cwd: "/w", repository: "acme/web", number: 7 }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + expect(argsOfCall(1)[1]).toContain("page=2"); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a query", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from its own diff, and pages inside it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + const commitPath = + "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; + expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); + // The whole path, not just the page: a cursor branch that dropped the commit would still + // ask for page 2, of the merge request's own diff. + expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); + assert.isNull(second.nextCursor); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a path", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "../../merge_requests/8/diffs", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reports a commit with no parent as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); + if (error._tag === "GitLabDiffCommitParentUnavailableError") { + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/first.ts", + newPath: "src/first.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); + expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("binary\uFFFDcontents", false, true)), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // A byte-truncated prefix: valid JSON never survives the cut. + Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + // An empty slice with no cursor would report every file from this page on as already + // read, which is the one answer that loses a change without saying so. + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("offers no squash when the project does not say it allows one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); + }), + ); + + it.effect("reads the project's merge settings as its merge capabilities", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); + }), + ); + + it.effect("fails the read when GitLab returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("fails when the authenticated account has no username", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); + }), + ); + + it.effect("walks the notes until GitLab answers with a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { comments, truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(0).join(" ")).toContain("page=1"); + expect(argsOfCall(1).join(" ")).toContain("page=2"); + assert.strictEqual(comments.length, 102); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the note walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // GitLab that never answers short: the walk has to end itself. + mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reads a positioned discussion as a thread anchored to its line", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: "abc123", + notes: [ + { + id: 1, + body: "rename this", + author: { username: "bilal", avatar_url: "https://avatars/b.png" }, + created_at: "2026-07-01T00:00:00Z", + resolvable: true, + resolved: true, + position: { + position_type: "text", + new_path: "src/a.ts", + old_path: "src/a.ts", + new_line: 12, + old_line: null, + }, + }, + { + id: 2, + body: "done", + author: { username: "julius" }, + created_at: "2026-07-01T01:00:00Z", + }, + ], + }, + // A plain note is the timeline's business, not the diff's. + { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, + ]), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { threads } = yield* cli.listDiscussions({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "abc123", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + assert.strictEqual(threads[0]?.comments.length, 2); + }), + ); + + it.effect("sends a review as its comments, then its summary, then the verdict", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [ + { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + ], + }); + + // The diff revisions first, because a positioned comment cannot be placed without them. + expect(argsOfCall(0)[1]).toContain("merge_requests/7"); + expect(argsOfCall(1)[1]).toContain("/discussions"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ + body: "why remove?", + position: { + base_sha: "base", + head_sha: "head", + start_sha: "start", + position_type: "text", + // A renamed file is the only case the two differ, and GitLab cannot place a + // position that names the same path on both sides of the rename. + old_path: "src/a.ts", + new_path: "src/b.ts", + old_line: 4, + }, + }); + expect(argsOfCall(2)[1]).toContain("/notes"); + // The verdict goes last, so a review that failed part-way is never an approval. + expect(argsOfCall(3)[1]).toContain("/approve"); + }), + ); + + it.effect("does not ask for diff revisions when a review carries no line comments", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "One thought.", + comments: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)[1]).toContain("/notes"); + }), + ); + + it.effect("resolves a discussion in place rather than posting to it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setDiscussionResolution({ + cwd: "/w", + repository: "acme/web", + number: 7, + discussionId: "abc123", + resolved: true, + }); + + expect(argsOfCall(0)).toContain("--method"); + expect(argsOfCall(0)).toContain("PUT"); + expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); + }), + ); + + it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: null, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "", + comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + }), + ); + + // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a + // positioned comment. + assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); + }), + ); + + it.effect("reads who has access to the project and who is already on the merge request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }])), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); + // The author is left out, and whoever GitLab already has as a reviewer is marked. + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["5", true], + ["9", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: true, + }); + + // GitLab replaces the whole set, so the reviewer already on the merge request has to be + // sent back with the new one or the request would take them off it. + expect(argsOfCall(1)).toContain("PUT"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); + + it.effect("ignores an id GitLab could not have handed out, which names nobody", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "octocat" }], + requested: true, + }); + + // Sending it as a number would rewrite the reviewer set around something nobody chose. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts new file mode 100644 index 00000000000..4cbb74d32a6 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -0,0 +1,1153 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import { + decodeCommitDiffRefsJson, + decodeCommitsJson, + decodeDiffRefsJson, + decodeDiscussionsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeProjectMergeCapabilitiesJson, + decodeProjectUsersJson, + decodeViewerJson, + type GitLabDiffRefs, + type GitLabMergeRequestDetail, + type GitLabMergeRequestListItem, + type GitLabProjectUsers, +} from "./gitLabMergeRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitLabMergeRequestReadError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestReadError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitLab CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: glab answered, the account it answered for just has no username. */ +export class GitLabViewerUnavailableError extends Schema.TaggedErrorClass()( + "GitLabViewerUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned no username for the authenticated account."; + } + + override get message(): string { + return `GitLab CLI failed in getViewerUsername: ${this.detail}`; + } +} + +/** Not a decode failure: GitLab answered, the merge request just has no revisions to place a + * comment against. */ +export class GitLabDiffRefsUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffRefsUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return "The merge request reported no diff revisions."; + } + + override get message(): string { + return `GitLab CLI failed in getDiffRefs: ${this.detail}`; + } +} + +/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ +export class GitLabDiffCursorError extends Schema.TaggedErrorClass()( + "GitLabDiffCursorError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The diff cursor was not one this merge request handed out."; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this project could hold. */ +export class GitLabDiffCommitError extends Schema.TaggedErrorClass()( + "GitLabDiffCommitError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; + } +} + +/** The commit exists and decoded, but it has no parent to use as the old revision. */ +export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffCommitParentUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + commit: Schema.String, + }, +) { + get detail(): string { + return `Commit ${this.commit} reported no parent revision.`; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; + } +} + +/** A blob exists, but expanding it would be unsafe or would not produce text. */ +export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffFileContentsUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + path: Schema.String, + reason: Schema.Literals(["oversized", "binary"]), + }, +) { + get detail(): string { + return this.reason === "oversized" + ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` + : `The diff file '${this.path}' is binary.`; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; + } +} + +export type GitLabPullRequestCliError = + | GitLabCli.GitLabCliError + | GitLabMergeRequestReadError + | GitLabDiffCursorError + | GitLabDiffCommitError + | GitLabDiffCommitParentUnavailableError + | GitLabDiffFileContentsUnavailableError + | GitLabDiffRefsUnavailableError + | GitLabViewerUnavailableError; + +/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ +const MAX_PAGE_SIZE = 100; +/** Commit history is read one page deep; the rest of a long history stays on GitLab. */ +const COMMIT_PAGE_SIZE = 100; +/** + * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at + * a hundred, so this is a thousand notes and a thousand discussions — more than any merge + * request a person is reading holds, and a walk that ends whatever the host has. + */ +const CONVERSATION_PAGES = 10; +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** Raw GitLab rows consumed to produce this page, including malformed rows. */ + readonly cursorAdvance: number; +} + +export interface GitLabMergeRequestDiffSlice { + readonly patch: string; + /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + readonly truncated: boolean; + /** Where the next slice starts, or null once the patch is whole. */ + readonly nextCursor: string | null; +} + +export class GitLabPullRequestCli extends Context.Service< + GitLabPullRequestCli, + { + readonly getViewerUsername: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text for GitLab's own `search`, which matches title and description. */ + readonly query?: string | undefined; + /** Where to carry on from in GitLab's stable update-ordered row set. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + readonly getMergeRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listNotes: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly listCommits: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect, GitLabPullRequestCliError>; + + readonly getMergeRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the merge request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect; + + readonly getMergeRequestDiffFileContents: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }) => Effect.Effect< + { readonly oldContents: string; readonly newContents: string }, + GitLabPullRequestCliError + >; + + readonly getProjectMergeCapabilities: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + /** + * Who this merge request may be sent to, and who it has already been sent to. Two reads at + * once, because GitLab keeps the people with access on the project and the reviewers on the + * merge request, and neither answers for the other. + */ + readonly listReviewerCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runMergeRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly commentOnMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly listDiscussions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly threads: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly submitReview: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToDiscussion: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setDiscussionResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly resolved: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitLabPullRequestCli") {} + +/** The REST API addresses a project by its URL-encoded full path. */ +function projectPath(repository: string): string { + return encodeURIComponent(repository.trim()); +} + +function stateParam(state: PullRequestListState): string { + // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, + // and it spans every state under `all`. + return state === "open" ? "opened" : state; +} + +function involvementParams(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return [["author_username", input.viewer]]; + case "reviewing": + return [["reviewer_username", input.viewer]]; + case "all": + return []; + } +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a query, so it is parsed rather + * than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +function searchParams(search: string | undefined): ReadonlyArray { + const trimmed = search?.trim() ?? ""; + return trimmed.length === 0 ? [] : [["search", trimmed]]; +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return [ + "merge", + // glab turns on auto-merge whenever a pipeline is running. The button means merge now. + "--auto-merge=false", + "--yes", + ...(mergeMethod === "squash" ? ["--squash"] : []), + ...(mergeMethod === "rebase" ? ["--rebase"] : []), + ]; + case "ready": + return ["update", "--ready"]; + case "draft": + return ["update", "--draft"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const api = (input: { + readonly cwd: string; + readonly path: string; + readonly method?: string; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly timeoutMs?: number; + }) => + gitlab.execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A raw body from stdin: argv is visible in process listings and is echoed back + // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no + // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. + ...(input.stdin === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }); + + /** + * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is + * bounded twice over: it stops on a short page or once the extra row that reveals a next + * page has been read, and it never asks for more pages than the caller's page needs. The + * second bound is what makes it terminate when every row on a page fails to decode, which + * leaves nothing collected but does not mean GitLab has run out of rows. + */ + const listPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly page: number; + readonly collected: ReadonlyArray; + readonly cursorAdvance: number; + }): Effect.Effect => { + // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has + // no tie-breaker, so a page where many rows share the boundary would otherwise return the + // same prefix forever. `delivered` is the stable offset the service has already handed over. + const delivered = input.cursor?.delivered ?? 0; + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const firstPage = Math.floor(delivered / perPage) + 1; + const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; + // A page made entirely of malformed rows has no item from which the service can build a + // continuation. Bound the walk to the raw span this request asked for rather than recursing + // forever on a host that keeps returning full unusable pages. + const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests?${query([ + ["state", stateParam(input.state)], + ...involvementParams(input), + // The listing is read through `glab api` rather than `glab mr list`, so the search is + // the REST API's own `search` parameter — the one `mr list --search` passes on. It + // matches title and description, and travels URL-encoded like every other value here, + // so no text in it can become a parameter of its own. + ...searchParams(input.query), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(input.page)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.collected, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodeMergeRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listMergeRequests", + cause: decoded.failure, + }), + ); + } + const pageItems: GitLabMergeRequestListItem[] = []; + const pageRawIndexes: number[] = []; + for (const [index, item] of decoded.success.items.entries()) { + const rawIndex = decoded.success.rawIndexes[index]!; + if (rawIndex < skipOnFirstPage) continue; + pageItems.push(item); + pageRawIndexes.push(rawIndex); + } + const remaining = input.limit - input.collected.length; + const lastItemRawIndex = pageRawIndexes[remaining - 1]; + if (lastItemRawIndex !== undefined) { + const consumed = lastItemRawIndex + 1 - skipOnFirstPage; + return Effect.succeed({ + items: [...input.collected, ...pageItems.slice(0, remaining)], + truncated: + lastItemRawIndex + 1 < decoded.success.rawCount || + decoded.success.rawCount === perPage, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + const collected = [...input.collected, ...pageItems]; + const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); + // Counted before decoding, so a skipped malformed row cannot end paging early. + const exhausted = decoded.success.rawCount < perPage; + if (exhausted) { + return Effect.succeed({ + items: collected, + truncated: false, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + if (input.page >= lastPage) { + return Effect.succeed({ + items: collected, + truncated: true, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + return listPage({ + ...input, + page: input.page + 1, + collected, + cursorAdvance: input.cursorAdvance + consumed, + }); + }), + ); + }; + + /** + * One page of a merge request's files, as a patch that stands on its own. GitLab pages + * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the + * caller carries on from it for as long as GitLab keeps handing full pages back. + * + * A named commit is read from the commit's own diff, which answers in the same shape and pages + * the same way. + */ + const diffPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/${ + input.commit === undefined + ? `merge_requests/${input.number}/diffs` + : `repository/commits/${input.commit}/diff` + }?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ])}`, + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => { + // A byte-truncated response is a JSON prefix, so this page cannot be read at all. + // Answering with no cursor would call the diff whole while silently dropping this page + // and every one after it, so the read fails and says which page could not be had. + if (result.stdoutTruncated) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: new Error( + `Page ${input.page} of the merge request diff was too large to read.`, + ), + }), + ); + } + const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: decoded.failure, + }), + ); + } + const patch = decoded.success.patch; + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; + return Effect.succeed({ + // The slice ends on a newline, so a file GitLab gave a header and no hunks for does + // not run into the first line of the next slice. + patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), + truncated: decoded.success.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + }); + }), + ); + + /** + * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short + * page is the only thing that says it is done — and the raw count decides, not the kept one: + * the notes GitLab wrote itself are dropped, and a whole page of them still means there is + * more to read. + */ + const notesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ["order_by", "created_at"], + ["sort", "asc"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeNotesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listNotes", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.comments]; + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ comments: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ comments: collected, truncated: true }) + : notesPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** The positioned discussions, walked the same way and stopped by the same bound. */ + const discussionsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly threads: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeDiscussionsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listDiscussions", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.threads]; + // The raw count again: this endpoint returns the plain notes too, so a full page of + // those is not the end of the positioned ones. + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ threads: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ threads: collected, truncated: true }) + : discussionsPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** + * The revisions a positioned comment is written against. GitLab resolves a comment's line + * against these three shas, so a review with line comments cannot be sent without them. + */ + const getDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getDiffRefs", + cause: decoded.failure, + }), + ); + } + // A merge request with no diff refs is a well-formed answer that cannot carry a + // positioned comment — a dead end, but not something that failed to be read. + return decoded.success === null + ? Effect.fail( + new GitLabDiffRefsUnavailableError({ + command: "glab", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + const getCommitDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly commit: string; + readonly allowRoot: boolean; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiffFileContents", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? input.allowRoot + ? Effect.succeed({ + baseSha: "", + headSha: input.commit, + startSha: "", + }) + : Effect.fail( + new GitLabDiffCommitParentUnavailableError({ + command: "glab", + cwd: input.cwd, + commit: input.commit, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + /** + * The merge request itself, which several calls need for different parts of it: the detail for + * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. + */ + const mergeRequestDetail = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDetail", + cause: decoded.failure, + }), + ); + }), + ); + + /** The people with access to the project, one page deep. */ + const projectUsers = (input: { + readonly cwd: string; + readonly repository: string; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/users?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectUsersJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReviewerCandidates", + cause: decoded.failure, + }), + ); + }), + ); + + return GitLabPullRequestCli.of({ + getViewerUsername: (input) => + api({ cwd: input.cwd, path: "user" }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeViewerJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getViewerUsername", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ), + + listMergeRequests: (input) => { + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; + return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); + }, + + getMergeRequestDetail: mergeRequestDetail, + + listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), + + listCommits: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( + [ + ["per_page", String(COMMIT_PAGE_SIZE)], + ["with_stats", "true"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeCommitsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listCommits", + cause: decoded.failure, + }), + ); + }), + ), + + getMergeRequestDiff: (input) => { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + } + const target = { + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }; + if (input.cursor === undefined) { + return diffPage({ ...target, page: 1 }); + } + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) + : diffPage({ ...target, page }); + }, + + getMergeRequestDiffFileContents: (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + } + const refs = yield* input.commit === undefined + ? getDiffRefs(input) + : getCommitDiffRefs({ + cwd: input.cwd, + repository: input.repository, + commit: input.commit, + allowRoot: input.changeType === "new", + }); + + const readFile = (revision: string, filePath: string) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( + filePath, + )}/raw?ref=${encodeURIComponent(revision)}`, + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitLabDiffFileContentsUnavailableError({ + command: "glab", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readFile(refs.headSha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }), + + getProjectMergeCapabilities: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}?license=false`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getProjectMergeCapabilities", + cause: decoded.failure, + }), + ); + }), + ), + + listReviewerCandidates: (input) => + Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( + Effect.map(([mergeRequest, users]) => { + const author = mergeRequest.author?.login; + const requested = new Set(mergeRequest.reviewRequestLogins); + return { + // The author is dropped rather than shown unusable: GitLab refuses to make the person + // who opened a merge request its reviewer. + candidates: users.candidates.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.login) }], + ), + truncated: users.rawCount >= MAX_PAGE_SIZE, + }; + }), + ), + + setReviewerRequest: (input) => + mergeRequestDetail(input).pipe( + Effect.flatMap((mergeRequest) => { + // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the + // whole set, so the set that is already there is read first and the change applied to + // it. Asking again for somebody already on it writes the same set back, which is how + // GitLab re-requests a review. + const ids = new Set(mergeRequest.reviewerIds); + for (const reviewer of input.reviewers) { + const id = Number(reviewer.id); + // A candidate GitLab did not name is not an id it would accept, and sending it would + // rewrite the reviewer set around a number nobody chose. + if (!Number.isSafeInteger(id) || id <= 0) continue; + if (input.requested) ids.add(id); + else ids.delete(id); + } + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + stdin: JSON.stringify({ reviewer_ids: [...ids] }), + }); + }), + Effect.asVoid, + ), + + runMergeRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return gitlab + .execute({ + cwd: input.cwd, + args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], + }) + .pipe(Effect.asVoid); + }, + + commentOnMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a + // literal `true` or a number, and a comment body is text either way. + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), + + submitReview: (input) => + Effect.gen(function* () { + const project = projectPath(input.repository); + const mergeRequest = `projects/${project}/merge_requests/${input.number}`; + // GitLab has no pending review to attach comments to, so a review is replayed as the + // requests it is made of: the line comments, then the summary, then the verdict. A + // failure part-way therefore leaves what was already posted in place, which is why + // the verdict goes last — a half-sent review is never an approval. + if (input.comments.length > 0) { + const refs = yield* getDiffRefs(input); + yield* Effect.forEach( + input.comments, + (comment) => + api({ + cwd: input.cwd, + path: `${mergeRequest}/discussions`, + method: "POST", + stdin: JSON.stringify({ + body: comment.body, + position: { + base_sha: refs.baseSha, + head_sha: refs.headSha, + start_sha: refs.startSha, + position_type: "text", + // Both paths are sent because GitLab resolves a position against both + // sides of the diff. They differ only for a renamed file, which is why the + // draft carries the name the file had before the change. + old_path: comment.oldPath ?? comment.path, + new_path: comment.path, + ...(comment.side === "left" + ? { old_line: comment.line } + : { new_line: comment.line }), + }, + }), + }), + { discard: true }, + ); + } + if (input.body.trim().length > 0) { + yield* api({ + cwd: input.cwd, + path: `${mergeRequest}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: + // glab coerces a field that reads as a literal `true` or a number. + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdin: JSON.stringify({ body: input.body }), + }); + } + if (input.verdict === "approve") { + yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); + } + }), + + replyToDiscussion: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}/notes`, + method: "POST", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + setDiscussionResolution: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}`, + method: "PUT", + stdin: JSON.stringify({ resolved: input.resolved }), + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts new file mode 100644 index 00000000000..55bbbe38d65 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { gitLabViewerPermissions } from "./GitLabPullRequestProvider.ts"; + +describe("gitLabViewerPermissions", () => { + it("offers everything to a viewer GitLab says can merge", () => { + expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. + requestReviewers: true, + }); + }); + + it("keeps merge from a viewer GitLab says cannot", () => { + // `user.can_merge` already accounts for the role, the approval rules and a protected target + // branch, so it is the one answer here that does not have to be inferred. + expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + requestReviewers: true, + }); + }); + + it("treats an author with read access as any other reader, which is all GitLab says", () => { + // Its REST API names no relationship between the viewer and the merge request beyond + // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken + // from the one person entitled to them. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ + "ready", + "draft", + "close", + "reopen", + ]); + }); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts new file mode 100644 index 00000000000..7ef1aedb204 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -0,0 +1,216 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["merge", "ready", "draft", "close", "reopen"], + // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. + mergeMethods: ["merge", "squash", "rebase"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + // No "changes requested": GitLab has approval and unresolved discussions, and nothing that + // says a merge request has been reviewed and rejected. + verdicts: ["comment", "approve"], + }, + reviewers: { request: true, listCandidates: true }, +}; + +/** + * What the signed-in account may do here. GitLab answers exactly one of these questions per + * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing + * narrowed. + * + * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never + * whether they opened this merge request — and its author may close it, reopen it and move it in + * and out of draft whatever their role, just as the author of a note may resolve the discussion + * it started. Withholding those controls from the one person entitled to them is the worse of the + * two mistakes, so they are offered and GitLab explains any refusal itself. + * + * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author + * and from anyone with the Developer role, and states neither of those two facts here. + */ +export function gitLabViewerPermissions(input: { + readonly viewerCanMerge: boolean; +}): PullRequestViewerPermissions { + return { + actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.viewerCanMerge), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + }; +} + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitLabPullRequestCli.GitLabPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitLabCliUnavailableError") return "missing-tool"; + if (error._tag === "GitLabCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => + new PullRequestProviderError({ + provider: "gitlab", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "gitlab", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listMergeRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + // GitLab is asked for its merge requests by update, newest first, whether or not it is + // being carried on from — so every page it answers is one a cursor can continue. + Effect.map((batch) => ({ ...batch, continues: true })), + ), + + getChangeRequest: (input) => + // GitLab splits a merge request across four endpoints, so they are read together. + Effect.all( + [ + cli.getMergeRequestDetail(input), + cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + // The conversation and the commit list are worth degrading for: neither is reason + // to blank a merge request that was read successfully. An unread conversation counts + // as truncated, so it does not present as one with no comments. + cli + .listNotes(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + cli + .listDiscussions(input) + .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), + ], + { concurrency: 5 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([ + mergeRequest, + mergeCapabilities, + notes, + commits, + discussions, + ]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + comments: notes.comments, + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads, + commits, + mergeCapabilities, + // Off the merge request read above, which was being made anyway. + viewerPermissions: gitLabViewerPermissions(mergeRequest), + }), + ), + ), + + // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge + // request, so there is no cheaper thing to ask GitLab. + getViewerPermissions: (input) => + cli + .getMergeRequestDetail(input) + .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), + + getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + // Users only: GitLab requests a review of a person, and the groups that can stand in for one + // appear in approval rules rather than in a merge request's reviewers. + listReviewerCandidates: (input) => + cli + .listReviewerCandidates({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + cli + .runMergeRequestAction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToDiscussion({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setThreadResolution: (input) => + cli + .setDiscussionResolution({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts new file mode 100644 index 00000000000..5e4fc82085e --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -0,0 +1,378 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestActor, + PullRequestCapabilities, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + PullRequestViewerPermissions, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every provider reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. + * + * `reason` is the part the service acts on: a missing or unauthenticated tool disables the + * provider for the whole workspace, while anything else is specific to the request. + */ +export class PullRequestProviderError extends Schema.TaggedErrorClass()( + "PullRequestProviderError", + { + provider: SourceControlProviderKindSchema, + operation: Schema.String, + reason: Schema.Literals(["missing-tool", "unauthenticated", "failed"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} + +/** A change request as the provider sees it, before the service attaches project context. */ +export interface ProviderChangeRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + /** Accounts with a review requested. Team-level requests are excluded by each provider. */ + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface ProviderChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the page size asked for. */ + readonly truncated: boolean; + /** + * Optional count-based cursor advance. Most hosts advance by the rows delivered after local + * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too. + */ + readonly cursorAdvance?: number; + /** + * This page can be carried on from, so the service may hand the caller a cursor for it. False + * where the host answered in an order a cursor means nothing in, which leaves a larger `limit` + * as the only way to the rest — what every listing did before there were cursors. + */ + readonly continues: boolean; +} + +/** + * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built + * by the service out of the slice it just handed over, so the boundary that decides whether a row + * arrives twice or not at all is decided in one place rather than in four. + */ +export interface ProviderListCursor { + /** + * The instant of the oldest row already handed over, checked against a timestamp's shape before + * it gets here because it goes into a host's own filter. Asked for inclusively: several rows + * share one instant often enough — a bot that touches eight change requests writes one timestamp + * on all eight — and asking for strictly older would lose whichever of them the slice ended + * before. The service drops the ones it has already sent. + */ + readonly updatedBefore: string; + /** + * How many provider rows this repository has consumed so far, for a host that carries on by + * counting rather than by date. Usually this is the number handed over; malformed raw rows may + * count too when the provider reports a `cursorAdvance`. + */ + readonly delivered: number; +} + +/** One repository's row inside an answer that spans several of them. */ +export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { + /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */ + readonly repository: string; +} + +/** + * One slice of a host read across several repositories at once, newest update first across all + * of them. There is no per-repository page here because the host was asked one question: the + * caller splits the rows by `repository` and works out where each of them carries on from the + * oldest row in the slice, which every repository the slice covers is now read up to. + */ +export interface ProviderBatchedChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the slice asked for, for any of the repositories. */ + readonly truncated: boolean; +} + +/** The line counts for one change request, which a listing may leave for a second read. */ +export interface ProviderChangeRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + readonly comments: ReadonlyArray; + /** + * The host's own count of the conversation, which a bounded read can fall short of. A host + * that reports no count of its own answers with what it handed over, which is the same number + * once the read went to the end. + */ + readonly commentCount: number; + readonly commentsTruncated: boolean; + readonly reviewThreads: ReadonlyArray; + readonly commits: ReadonlyArray; + readonly mergeCapabilities: PullRequestMergeCapabilities; + /** + * What the signed-in account may do with this change request. Read from calls this detail + * already makes, so knowing it costs nothing extra. + */ + readonly viewerPermissions: PullRequestViewerPermissions; +} + +export interface ProviderDiffSlice { + readonly patch: string; + /** Something in this slice could not be shown, as opposed to there being more slices. */ + readonly truncated: boolean; + readonly nextCursor: string | null; +} + +export interface ProviderDiffFileContents { + readonly oldContents: string; + readonly newContents: string; +} + +export interface ProviderRepositoryRef { + readonly cwd: string; + /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ + readonly repository: string; + /** + * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo` + * exists on github.com and on a GitHub Enterprise install, and only the caller knows which + * one a project's remote points at. + */ + readonly host: string; +} + +/** + * One host's change requests. Implementations own their own tool and JSON shapes and hand back + * the neutral types above; anything a host cannot do is declared in `capabilities` rather than + * failing at call time. + */ +export interface PullRequestProviderApi { + readonly kind: SourceControlProviderKind; + readonly capabilities: PullRequestCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listChangeRequests: ( + input: ProviderRepositoryRef & { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Free text to narrow the listing by, as the host understands it. A host with no text + * filter of its own ignores it and answers with the page it would have answered with + * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer + * rather than a wrong one. + */ + readonly query?: string | undefined; + /** + * Where to carry on from, rather than reading this repository from its newest row. Absent + * asks for the first slice, which is every listing that has not been continued. + */ + readonly cursor?: ProviderListCursor | undefined; + }, + ) => Effect.Effect; + + /** + * The same listing for a whole host in one request, for a host that has a search across + * repositories. Optional: three of the four hosts here have no such API, and breaking the port + * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity. + * The caller falls back to `listChangeRequests` per repository where this is absent, and where + * it fails. + * + * `limit` is the whole slice rather than a size per repository, because that is the shape of + * the answer: the newest `limit` rows across every repository named, which is exactly the rows + * a page ordered by update shows. + * + * `cursor` is one boundary for all of them, so a caller with repositories standing at different + * boundaries asks in groups rather than in one call. + */ + readonly listChangeRequestsAcross?: (input: { + /** Any checkout on the host, which is what the tool is run in. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + /** + * The line counts for rows a listing has already handed over. Only implemented by a provider + * whose listing leaves them out — for everyone else the numbers arrived with the row, and the + * caller has nothing to ask for. + */ + readonly listChangeRequestStats?: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, PullRequestProviderError>; + + readonly getChangeRequest: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so + * a request that reached the server without going past the page is refused by what the host + * says rather than by what the client claimed — and asked freshly, because access granted or + * taken away since the page loaded is exactly the case this guards. + * + * Implementations read the cheapest thing that answers it, which for a host with nothing to say + * is no request at all. + */ + readonly getViewerPermissions: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can + * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages + * hands back whatever it needs to find the next slice. + */ + readonly getDiff: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the change request carries. */ + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + + /** + * Full files at the exact revisions the host used for its patch. Optional where the provider + * exposes no diff at all; the service refuses expansion there just as it refuses the patch. + */ + readonly getDiffFileContents?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }, + ) => Effect.Effect; + + readonly runAction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }, + ) => Effect.Effect; + + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, + ) => Effect.Effect; + + /** + * Sends a whole review at once. Only called for a verdict the host declared in + * `capabilities.review.verdicts`, and with line comments only where it declared + * `inlineComment`. + */ + readonly submitReview: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }, + ) => Effect.Effect; + + /** + * The people this viewer may ask for a review, with whoever has already been asked marked as + * such. Only called when `capabilities.reviewers.listCandidates` is true. + * + * The author is left out by each provider rather than by the caller, because only the provider + * knows how the host spells the same person in a candidate list and on a pull request. + */ + readonly listReviewerCandidates: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Asks for a review, or takes the request back. Only called when + * `capabilities.reviewers.request` is true. + * + * One call for both directions, because that is what every host does with them: GitHub posts and + * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way. + * Asking again somebody who has already reviewed is a request like any other — which is how a + * re-request is made. + */ + readonly setReviewerRequest: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + readonly requested: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.reply` is true. */ + readonly replyToThread: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly body: string; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.resolve` is true. */ + readonly setThreadResolution: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly resolved: boolean; + }, + ) => Effect.Effect; +} diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts new file mode 100644 index 00000000000..84a4ebef057 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -0,0 +1,59 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; + +export class PullRequestProviderRegistry extends Context.Service< + PullRequestProviderRegistry, + { + /** Null for a host with no implementation, which the service reports as unsupported. */ + readonly get: (kind: SourceControlProviderKind) => PullRequestProviderApi | null; + readonly kinds: ReadonlyArray; + } +>()("t3/pullRequest/PullRequestProviderRegistry") {} + +/** Exported for tests, which stand a registry up from providers they supply themselves. */ +export function fromProviders( + providers: ReadonlyArray, +): PullRequestProviderRegistry["Service"] { + const byKind = new Map(providers.map((provider) => [provider.kind, provider])); + return { + get: (kind) => byKind.get(kind) ?? null, + kinds: providers.map((provider) => provider.kind), + }; +} + +/** + * The hosts this build can read change requests from. A host with no entry here still shows up + * in the provider list as unimplemented, so its projects are explained rather than missing. + */ +export const make = Effect.map( + Effect.all([ + GitHubPullRequestProvider.make, + GitLabPullRequestProvider.make, + BitbucketPullRequestProvider.make, + AzureDevOpsPullRequestProvider.make, + ]), + fromProviders, +); + +export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( + Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubCli.layer))), + Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), + Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), + Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts new file mode 100644 index 00000000000..eb28d165010 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -0,0 +1,2198 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { + OrchestrationProjectShell, + ProjectId, + PullRequestReviewCapabilities, + PullRequestReviewerCapabilities, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +function project(input: { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + // The host defaults from the provider, so a fixture only names one when the point of the + // test is two hosts of the same kind. + const host = input.host ?? (input.provider === "gitlab" ? "gitlab.com" : "github.com"); + return { + id: input.id as ProjectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: `${host}/${input.repository}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function changeRequest(number: number, updatedAt: string): ProviderChangeRequest { + return { + number, + title: `Change request ${number}`, + url: `https://host/pull/${number}`, + author: { login: "octocat", name: null, avatarUrl: null }, + headBranch: `feat/${number}`, + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + reviewRequestLogins: [], + labels: [], + }; +} + +function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { + return new PullRequestProviderError({ + provider, + operation: "getViewer", + reason, + detail: `${provider} is not usable.`, + }); +} + +const requestFailed = new PullRequestProviderError({ + provider: "github", + operation: "listChangeRequests", + reason: "failed", + detail: "HTTP 404", +}); + +/** Everything a host could offer, so a fixture only narrows what its own test is about. */ +const FULL_REVIEW: PullRequestReviewCapabilities = { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], +}; + +const FULL_REVIEWERS: PullRequestReviewerCapabilities = { request: true, listCandidates: true }; + +/** A provider whose every call is supplied by the test; anything unset succeeds emptily. */ +function fakeProvider( + kind: SourceControlProviderKind, + overrides: Partial = {}, +): PullRequestProviderApi { + return { + kind, + capabilities: { + diff: true, + comment: true, + actions: ["merge", "ready", "draft", "close", "reopen"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewer: () => Effect.succeed("bilal"), + // A viewer who may do everything the host can, so a test only narrows what it is about. + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }), + getChangeRequest: () => Effect.die("unused"), + getDiff: () => Effect.die("unused"), + runAction: () => Effect.void, + comment: () => Effect.void, + submitReview: () => Effect.void, + replyToThread: () => Effect.void, + setThreadResolution: () => Effect.void, + listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), + setReviewerRequest: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly providers: ReadonlyArray; +}) { + return PullRequestService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: input.projects, + threads: [], + updatedAt: "2026-07-01T00:00:00Z", + }), + }), + ), + ), + ); +} + +/** A row as a host that reads several repositories at once hands it over. */ +function batchedChangeRequest(number: number, repository: string, updatedAt: string) { + return { ...changeRequest(number, updatedAt), repository }; +} + +it.effect("reads nothing from a host with no implementation, but reports it", () => + Effect.gen(function* () { + const listed: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "notes", workspaceRoot: "/b" }), + project({ + id: "p3", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + listed.push(input.repository); + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(listed, ["pingdotgg/t3code"]); + assert.strictEqual(result.entries[0]?.provider, "github"); + // The GitLab project is explained rather than quietly missing from the page. + assert.deepStrictEqual( + result.providers.map((summary) => ({ + kind: summary.kind, + configured: summary.configured, + projectCount: summary.projectCount, + })), + [ + { kind: "github", configured: true, projectCount: 1 }, + { kind: "gitlab", configured: false, projectCount: 1 }, + ], + ); + }), +); + +it.effect("asks for a whole page of a host, and for the reader's own size when given one", () => + Effect.gen(function* () { + const limits: number[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + limits.push(input.limit); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.list({ state: "open", limit: 10 }); + + // Providers probe with one row over this, so 99 asks a host for 100 — the most GitHub and + // GitLab serve in one request. 100 here would cost a second round trip for a single row. + assert.deepStrictEqual(limits, [99, 10]); + }), +); + +it.effect("says where each repository carries on, and from nothing it has run out of", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: repository === "pingdotgg/t3code", + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The instant of the oldest row, how many rows have gone, and the row already sent at that + // instant. The repository that had nothing more is simply not in it. + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1", + }); + }), +); + +it.effect("offers no continuation for a host that cannot be carried on from", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: true, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // More rows exist and no cursor reaches them, which is what asking for a larger page is for. + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.nextCursors, {}); + }), +); + +it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(7, "2026-07-02T00:00:00Z")], + truncated: true, + cursorAdvance: 4, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(result.nextCursors, { + "dev.azure.com acme/web": "2026-07-02T00:00:00Z|4|7", + }); + }), +); + +it.effect("reads only the repositories it was asked to carry on with", () => + Effect.gen(function* () { + const listed: string[] = []; + const cursors: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + listed.push(input.repository); + cursors.push(input.cursor); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" }, + }); + + // The other repository is already on the page, and reading it again is the whole cost this + // is here to avoid. The host summaries stay over the workspace, because the switcher they + // fill is about the workspace rather than about this slice. + assert.deepStrictEqual(listed, ["acme/web"]); + assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 99 }]); + assert.strictEqual(result.providers.length, 1); + }), +); + +it.effect("keeps a row already sent at the boundary instant from arriving twice", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + // The boundary instant is asked for inclusively, so the host hands back the rows + // already sent at it alongside the ones beside them — which a strictly-older read + // would have lost instead. + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + changeRequest(9, "2026-07-01T00:00:00Z"), + ], + truncated: true, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, + }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [8, 9], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-01T00:00:00Z|3|9", + }); + }), +); + +it.effect("keeps the earlier exclusions when a slice ends on the instant it began on", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + ], + truncated: true, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|6" }, + }); + + // Eight rows can share one second, so a whole slice inside one is ordinary. The next read + // has to keep excluding 6 as well as the two just sent, or it hands 6 over again. + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [7, 8], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|3|6,7,8", + }); + }), +); + +it.effect("refuses a continuation it did not issue, before asking any host anything", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + ], + }); + + const error = yield* Effect.flip( + service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual( + error.message, + "Pull request operation list failed: The list could not be carried on from where it left off.", + ); + }), +); + +it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + // `cli-unauthenticated` would send the reader to `gh auth login` over a transient error. + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("reports an unusable host over a merely failing one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual(error.message.includes("glab"), true); + }), +); + +it.effect("lists every host that has an implementation", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/sub/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + fakeProvider("gitlab", { + listChangeRequests: (input) => + // Nested groups need the full path, not the last two segments. + input.repository === "group/sub/project" + ? Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + continues: true, + }) + : Effect.die("wrong repository identity"), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [entry.provider, entry.number]), + [ + ["gitlab", 2], + ["github", 1], + ], + ); + }), +); + +it.effect("narrows the listing to one host when asked", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", host: "gitlab.com" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["gitlab"], + ); + }), +); + +it.effect("tells two hosts of one kind apart in the switcher and the filter", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "on github.com", workspaceRoot: "/a", repository: "ping/one" }), + project({ + id: "p2", + title: "on the enterprise install", + workspaceRoot: "/b", + repository: "ping/two", + host: "ghe.example.com", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ host }) => + Effect.succeed({ + items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], + truncated: false, + continues: true, + }), + }), + ], + }); + + // Both hosts are GitHub, so a switcher keyed by provider kind would offer one pill for the + // two of them and no way to ask for either. + const all = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + all.providers.map((summary) => [summary.host, summary.kind, summary.projectCount]), + [ + ["github.com", "github", 1], + ["ghe.example.com", "github", 1], + ], + ); + + const scoped = yield* service.list({ state: "open", host: "ghe.example.com" }); + assert.deepStrictEqual( + scoped.entries.map((entry) => [entry.host, entry.number]), + [["ghe.example.com", 2]], + ); + }), +); + +it.effect("keeps one host listed when another is not set up", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["github"], + ); + assert.deepStrictEqual( + result.providers.map((summary) => [summary.kind, summary.configured]), + [ + ["github", true], + ["gitlab", false], + ], + ); + }), +); + +it.effect("fails as unavailable only when no host can be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => Effect.fail(unusable("github", "missing-tool")), + }), + ], + }); + + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual( + error._tag === "PullRequestUnavailableError" ? error.reason : null, + "cli-missing", + ); + }), +); + +it.effect("reads a repository once when several worktrees share it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "t3code worktree", + workspaceRoot: "/b", + repository: "PingDotGG/T3Code", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + calls += 1; + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(calls, 1); + assert.strictEqual(result.entries.length, 1); + }), +); + +it.effect("keeps healthy repositories when one of them cannot be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => + input.repository === "pingdotgg/broken" + ? Effect.fail(requestFailed) + : Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectTitle), + ["broken"], + ); + }), +); + +it.effect("tries another workspace on the same host for the viewer", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "broken", workspaceRoot: "/broken", repository: "acme/one" }), + project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/healthy" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "missing-tool")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 2); + assert.strictEqual(result.viewers["github.com"], "bilal"); + }), +); + +it.effect("refuses an action the host never claimed it could run", () => + Effect.gen(function* () { + let ran = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + // Bitbucket's shape: it can merge and close, but cannot reopen. + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + runAction: () => { + ran = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "reopen", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(ran); + }), +); + +it.effect("refuses an action this viewer may not take, and says what access it takes", () => + Effect.gen(function* () { + let ran: string | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The host merges; this account only reads it, and opened the change request — which + // is every contributor to a repository they do not own. + getViewerPermissions: () => + Effect.succeed({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + runAction: (input) => { + ran = input.action; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const error = yield* Effect.flip(service.runAction({ ...reference, action: "merge" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to merge."); + assert.strictEqual(ran, null); + + // What the author keeps whatever their access is still theirs to take. + yield* service.runAction({ ...reference, action: "close" }); + assert.strictEqual(ran, "close"); + }), +); + +it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + setThreadResolution: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setThreadResolution({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + threadId: "t1", + resolved: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "to resolve a review conversation."); + }), +); + +it.effect("asks nobody what the viewer may do when the host cannot do it at all", () => + Effect.gen(function* () { + let asked = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => { + asked = true; + return Effect.die("must not be called"); + }, + }), + ], + }); + + yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "reopen", + }), + ); + + // The capability check costs nothing; the permission read is a request, so it comes second. + assert.isFalse(asked); + }), +); + +it.effect("refuses a comment on a host that cannot post one", () => + Effect.gen(function* () { + let posted = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: false, + comment: false, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + comment: () => { + posted = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.comment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + body: "Looks good.", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(posted); + }), +); + +it.effect("keeps two hosts of one provider kind as two accounts", () => + Effect.gen(function* () { + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + // The same path on a different host: neither the viewer nor the row may be shared. + repository: "acme/web", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // Both repositories survive de-duplication, each with its own account. + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); + }), +); + +it.effect("reports repositories on a host that could not be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/api", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/cloud" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "unauthenticated")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The healthy host still lists, and the unreadable one is named rather than dropped. + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectId), + ["p2"], + ); + }), +); + +it.effect("flags a review request for the viewer but not on their own change request", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, + { + ...changeRequest(2, "2026-07-02T00:00:00Z"), + author: { login: "bilal", name: null, avatarUrl: null }, + reviewRequestLogins: ["bilal"], + }, + ], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.viewerReviewRequested), + [true, false], + ); + }), +); + +it.effect("refuses a repository that does not belong to the requested project", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a diff on a host that cannot produce one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on azure", + workspaceRoot: "/a", + repository: "org/project", + provider: "azure-devops", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: false, + comment: true, + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("rejects an empty comment before reaching the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })], + }); + + const error = yield* service + .comment({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + body: " ", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a verdict the host never claimed, without asking the provider", () => + Effect.gen(function* () { + let submitted = false; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + // GitLab's shape: it approves, and has nothing that rejects. + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve"], + }, + reviewers: FULL_REVIEWERS, + }, + submitReview: () => { + submitted = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.submitReview({ + projectId: "p1" as ProjectId, + repository: "group/project", + number: 1, + verdict: "request-changes", + body: "no", + comments: [], + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(submitted); + }), +); + +it.effect("refuses line comments on a host that takes only a summary", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, + reviewers: FULL_REVIEWERS, + }, + submitReview: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.submitReview({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + verdict: "comment", + body: "", + comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect( + "refuses a review with neither a summary nor a comment, but lets an approval through", + () => + Effect.gen(function* () { + let approved = false; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakeProvider("github", { + submitReview: () => { + approved = true; + return Effect.void; + }, + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + const error = yield* Effect.flip( + service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + + // An approval is a verdict in itself, so it needs no words. + yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] }); + assert.isTrue(approved); + }), +); + +it.effect("refuses to resolve a conversation on a host that cannot", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, + reviewers: FULL_REVIEWERS, + }, + setThreadResolution: () => Effect.die("must not be called"), + replyToThread: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + const resolveError = yield* Effect.flip( + service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }), + ); + const replyError = yield* Effect.flip( + service.replyToThread({ ...reference, threadId: "t1", body: "hi" }), + ); + + assert.strictEqual(resolveError._tag, "PullRequestOperationError"); + assert.strictEqual(replyError._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses an empty reply before it reaches the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { replyToThread: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.replyToThread({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + threadId: "t1", + body: " ", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a merge strategy the host does not offer", () => + Effect.gen(function* () { + let ranWith: string | null = null; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + // Azure DevOps's shape: it squashes as a completion option and has no rebase. + mergeMethods: ["merge", "squash"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + runAction: (input) => { + ranWith = input.mergeMethod ?? "merge"; + return Effect.void; + }, + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + // Every provider maps an unrecognised strategy to its own default, so letting this through + // would merge with the wrong one rather than fail. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(ranWith, null); + + yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" }); + assert.strictEqual(ranWith, "squash"); + }), +); + +it.effect("hands the provider the host its repository lives on", () => + Effect.gen(function* () { + const hosts: string[] = []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "enterprise", + workspaceRoot: "/a", + repository: "acme/web", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + hosts.push(input.host); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + + // The identity a project records is the path below its host, so the host has to travel + // separately or a GitHub Enterprise repository is read off github.com instead. + assert.deepStrictEqual(hosts, ["github.acme.dev"]); + }), +); + +it.effect("asks every host the reader's search, rather than filtering what came back", () => + Effect.gen(function* () { + const asked: Array = []; + const listing = (input: { readonly query?: string | undefined }) => { + asked.push(input.query); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: listing }), + fakeProvider("gitlab", { listChangeRequests: listing }), + ], + }); + + yield* service.list({ state: "open", query: "pull requests page" }); + + // A page holds one page per repository, so a search that stopped at the service could only + // find what was already loaded. + assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); + }), +); + +it.effect("asks for no search when the reader has typed nothing", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + asked.push(input.query); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [undefined]); + }), +); + +it.effect("asks another checkout who is signed in when the first one cannot answer", () => + Effect.gen(function* () { + const asked: string[] = []; + const service = yield* makeService({ + projects: [ + // One repository, checked out twice. The listing reads it once; the viewer lookup has + // two places to ask. + project({ + id: "p1", + title: "t3code (stale worktree)", + workspaceRoot: "/gone", + repository: "pingdotgg/t3code", + }), + project({ + id: "p2", + title: "t3code", + workspaceRoot: "/healthy", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => { + asked.push(input.cwd); + return input.cwd === "/gone" + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "not a git repository", + }), + ) + : Effect.succeed("bilal"); + }, + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // De-duplicating the listing must not throw away the checkouts the fallback needs: the + // host is readable, so it is read. + assert.deepStrictEqual(asked, ["/gone", "/healthy"]); + assert.strictEqual(result.entries.length, 1); + assert.strictEqual(result.providers[0]?.configured, true); + }), +); + +it.effect("refuses to ask for a review on a host that cannot, before any call is made", () => + Effect.gen(function* () { + let asked = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: { request: false, listCandidates: false }, + }, + getViewerPermissions: () => { + asked = true; + return Effect.die("must not be called"); + }, + setReviewerRequest: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.requestReviewers({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot ask somebody for a review."); + assert.isFalse(asked); + }), +); + +it.effect("refuses the candidate list on a host that has no such list to give", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: false, + comment: false, + actions: ["merge"], + mergeMethods: ["merge"], + search: false, + review: FULL_REVIEW, + // Azure's shape: it takes a reviewer, and names nobody who could be one. + reviewers: { request: true, listCandidates: false }, + }, + listReviewerCandidates: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot say who may review a change request."); + }), +); + +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => + Effect.gen(function* () { + let sent = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The host asks for reviews; this account only reads the repository. + getViewerPermissions: () => + Effect.succeed({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + setReviewerRequest: () => { + sent = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.requestReviewers({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to ask for a review."); + assert.isFalse(sent); + }), +); + +it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + listReviewerCandidates: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }), + ); + + assert.include(error.message, "You need write access on this repository to ask for a review."); + }), +); + +it.effect("hands the host's own candidate list back, and asks for it with the change request", () => + Effect.gen(function* () { + let askedFor: number | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listReviewerCandidates: (input) => { + askedFor = input.number; + return Effect.succeed({ + candidates: [ + { + id: "octocat", + kind: "user", + login: "octocat", + name: null, + avatarUrl: null, + isRequested: true, + }, + ], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const list = yield* service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + }); + + assert.strictEqual(askedFor, 4); + assert.deepStrictEqual( + list.candidates.map((candidate) => candidate.login), + ["octocat"], + ); + }), +); + +it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], { + concurrency: "unbounded", + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 1); + + // A different filter is a different answer, not a cache hit. + yield* service.list({ state: "all" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("an explicit invalidation makes the next listing ask the host again", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.invalidate({}); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + + // Forgetting one change request leaves the listings shared. + yield* service.invalidate({ + reference: { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("a mutation makes the next listing ask the host again, with no client asking", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "close", + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("does not cache a failed listing", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The viewer lookup is what fails the whole listing rather than one repository. + getViewer: () => { + hostCalls += 1; + return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal"); + }, + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + const second = yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + assert.strictEqual(second.providers[0]?.configured, true); + }), +); + +it.effect("reads a host's repositories in one search, and files the rows back under each", () => + Effect.gen(function* () { + const asked: Array> = []; + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + project({ + id: "p3", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: (input) => { + asked.push(input.repositories); + return Effect.succeed({ + items: [ + batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"), + batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + ], + truncated: false, + }); + }, + }), + // A host with no search across repositories keeps being asked one at a time. + fakeProvider("gitlab", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ + items: [changeRequest(3, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [["pingdotgg/t3code", "acme/web"]]); + assert.deepStrictEqual(separately, ["group/project"]); + // Ordered by update across every host, and each row under the project whose repository it + // came from. + assert.deepStrictEqual( + result.entries.map((entry) => [entry.projectId, entry.number]), + [ + ["p2", 1], + ["p1", 2], + ["p3", 3], + ], + ); + }), +); +it.effect("carries every repository of a slice on from the oldest row in it", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: () => + Effect.succeed({ + items: [ + batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"), + batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), + ], + truncated: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The boundary is the oldest row of the whole slice, not of each repository: `acme/web` has + // been read past its newest row, so only the rows sent at the boundary are named for it. + // `acme/docs`, which the slice holds nothing of, is not believed on silence alone — it is + // read on its own, and that read is what says whether it has anything at all. + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|2", + "github.com acme/web": "2026-07-02T00:00:00Z|2|3", + }); + }), +); +it.effect("carries a slice on without sending the rows it already sent", () => + Effect.gen(function* () { + const cursors: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + cursors.push(input.cursor); + return Effect.succeed({ + items: [ + batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), + batchedChangeRequest(4, "acme/web", "2026-07-02T00:00:00Z"), + ], + truncated: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|1|3" }, + }); + + // The boundary instant is asked for inclusively, so the row already sent at it comes back and + // is dropped here — and stays named in the next cursor, which has not moved off that instant. + assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 1 }]); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [4], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com acme/web": "2026-07-02T00:00:00Z|2|3,3,4", + }); + }), +); +it.effect("reads a workspace larger than one search in chunks, and merges them", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: Array.from({ length: 101 }, (_, index) => + project({ + id: `p${index}`, + title: `repo ${index}`, + workspaceRoot: `/w${index}`, + repository: `acme/repo${index}`, + }), + ), + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + asked.push(input.repositories.length); + return Effect.succeed({ + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), + truncated: false, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [100, 1]); + assert.strictEqual(result.entries.length, 101); + }), +); +it.effect("asks on its own for a repository a search answered nothing for", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return repository === "acme/docs" + ? Effect.fail(requestFailed) + : Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: () => + Effect.succeed({ + items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The slice had room and still held nothing of `acme/docs`, which is what a repository GitHub + // will not search looks like — so it is read the old way, and its failure is still reported + // against its own project. + assert.deepStrictEqual(separately, ["acme/docs"]); + assert.deepStrictEqual(result.errors, [ + { + projectId: "p2" as ProjectId, + projectTitle: "docs", + message: "acme/docs could not be read.", + }, + ]); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + }), +); +it.effect("reads the repositories one at a time when the search itself fails", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + listChangeRequestsAcross: () => Effect.fail(requestFailed), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // One failed question about two repositories is not two unreadable repositories. + assert.deepStrictEqual(separately.toSorted(), ["acme/docs", "acme/web"]); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.entries.length, 2); + }), +); +it.effect("fills in the line counts for the rows it is given", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestStats: (input) => { + asked.push(input.changeRequests); + return Effect.succeed([ + { repository: "acme/web", number: 1, additions: 12, deletions: 3 }, + ]); + }, + }), + // Its listing carries the counts already, so it has nothing to be asked. + fakeProvider("gitlab"), + ], + }); + + const result = yield* service.listStats({ + refs: [ + { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, + { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, + { projectId: "p2" as ProjectId, repository: "group/project", number: 3 }, + // Not the repository this project's remote points at, so it is dropped rather than asked. + { projectId: "p1" as ProjectId, repository: "evil/repo", number: 4 }, + ], + }); + + assert.deepStrictEqual(asked, [ + [ + { repository: "acme/web", number: 1 }, + { repository: "acme/web", number: 2 }, + ], + ]); + // Only the rows the host answered for; the other is left with whatever the listing had. + assert.deepStrictEqual(result.stats, [ + { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + additions: 12, + deletions: 3, + }, + ]); + }), +); +it.effect("keeps the rows when the line counts cannot be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }), + ], + }); + + const result = yield* service.listStats({ + refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], + }); + + assert.deepStrictEqual(result.stats, []); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 00000000000..c6e33410ddb --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,1624 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { + PullRequestOperationError, + PullRequestUnavailableError, + pullRequestHostOf, + pullRequestProviderRequirement, + type OrchestrationProjectShell, + type PullRequestAction, + type PullRequestActionInput, + type PullRequestCommentInput, + type PullRequestDetail, + type PullRequestDiffFileContentsInput, + type PullRequestDiffFileContentsResult, + type PullRequestDiffStat, + type PullRequestDiffInput, + type PullRequestDiffResult, + type PullRequestInvalidateInput, + type PullRequestListEntry, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListResult, + type PullRequestListStatsInput, + type PullRequestListStatsResult, + type PullRequestProviderSummary, + type PullRequestRef, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerRequestInput, + type PullRequestSubmitReviewInput, + type PullRequestThreadReplyInput, + type PullRequestThreadResolutionInput, + type SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + type ProviderChangeRequest, + type ProviderListCursor, + type PullRequestProviderApi, + type PullRequestProviderError, +} from "./PullRequestProvider.ts"; +import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; + +/** + * Rows per repository when the client does not ask for a page size, and rows per slice when a + * listing is carried on from a cursor. + * + * 99 and not 100, because every provider asks its host for one row over this to probe for a next + * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses + * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab + * caps `per_page` at. Asking for 100 here would request 101 and buy a whole second round trip for + * one row (measured: `gh pr list --limit 100` makes 1 HTTP request, `--limit 101` makes 2). + */ +const DEFAULT_REPOSITORY_LIST_LIMIT = 99; +/** + * Repositories read at once. Each one is a CLI process that spends nearly all its wall clock + * waiting on the host, so the useful ceiling is far above the core count; measured over 12 + * repositories on this listing's own command, 4 took ~12.7s, 8 ~8.9s and 12 ~4.9s, with 16 and 24 + * no faster because 12 already reads every repository in one wave. + */ +const REPOSITORY_CONCURRENCY = 12; +/** + * Repositories named in one read across a host. Measured against GitHub's search: six hundred + * `repo:` qualifiers in one query — 14.7KB of it — were all still honoured, and the answer took + * the same three to six seconds at twelve repositories as at four hundred. A hundred is well + * inside that and past the size of a workspace anyone opens, so a larger one reads in a handful + * of searches rather than in a request per repository. + */ +const REPOSITORY_SEARCH_CHUNK = 100; + +/** + * Every read leaves the process — a CLI per repository, against hosts whose limits are low + * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short + * while and concurrent identical reads share one request. The windows sit near the clients' + * own stale times: long enough that two people opening the same page cost one round trip, + * short enough that "cached" and "fresh" never need telling apart on screen. Reads that + * must not share — the refresh button, a client reloading after its own action — go through + * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. + */ +const LIST_CACHE_TTL = Duration.seconds(30); +const DETAIL_CACHE_TTL = Duration.seconds(15); +const DIFF_CACHE_TTL = Duration.seconds(60); +/** A commit is content-addressed, so its own diff cannot change under its key. */ +const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); +/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ +const LIST_STATS_CACHE_TTL = Duration.seconds(60); +/** + * How long a cache's last success may still be served while a fresh read runs behind it. + * Bounded by how the page actually revalidates: clients re-read on mount and once a minute + * while open, and every one of those reads repopulates the cache in the background — so in + * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches + * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation + * bumps the epochs and skips held answers entirely. + */ +const LIST_STALE_WINDOW = Duration.minutes(10); +const DETAIL_STALE_WINDOW = Duration.minutes(5); +const DIFF_STALE_WINDOW = Duration.minutes(10); +/** How long one host's signed-in login is believed without asking its CLI again. */ +const VIEWER_CACHE_TTL = Duration.minutes(10); +const LIST_CACHE_CAPACITY = 64; +const LIST_STATS_CACHE_CAPACITY = 32; +const DETAIL_CACHE_CAPACITY = 128; +const DIFF_CACHE_CAPACITY = 128; + +export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; + +export class PullRequestService extends Context.Service< + PullRequestService, + { + readonly list: ( + input: PullRequestListInput, + ) => Effect.Effect; + readonly listStats: ( + input: PullRequestListStatsInput, + ) => Effect.Effect; + readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly diff: ( + input: PullRequestDiffInput, + ) => Effect.Effect; + readonly diffFileContents: ( + input: PullRequestDiffFileContentsInput, + ) => Effect.Effect; + readonly runAction: (input: PullRequestActionInput) => Effect.Effect; + readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + readonly submitReview: ( + input: PullRequestSubmitReviewInput, + ) => Effect.Effect; + readonly replyToThread: ( + input: PullRequestThreadReplyInput, + ) => Effect.Effect; + readonly setThreadResolution: ( + input: PullRequestThreadResolutionInput, + ) => Effect.Effect; + readonly reviewerCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly requestReviewers: ( + input: PullRequestReviewerRequestInput, + ) => Effect.Effect; + readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; + } +>()("t3/pullRequest/PullRequestService") {} + +/** What a verdict is called when refusing it, so the sentence reads as an action. */ +const VERDICT_LABELS: Record = { + comment: "review", + approve: "approve", + "request-changes": "request changes on", +}; + +/** + * Why an action is refused to this viewer, said as the access it would take rather than as the + * refusal the host would have answered with. Merging is the one that needs write and nothing + * else; the other four are also the author's to take, whatever access they have. + */ +const ACTION_ACCESS_REFUSALS: Record = { + merge: "You need write access on this repository to merge.", + ready: + "You need write access on this repository, or to have opened this change request, to mark it ready for review.", + draft: + "You need write access on this repository, or to have opened this change request, to return it to a draft.", + close: + "You need write access on this repository, or to have opened this change request, to close it.", + reopen: + "You need write access on this repository, or to have opened this change request, to reopen it.", +}; + +/** + * Why asking for a review is refused, and why the menu behind it is too. Write access is what the + * hosts that state anything about this want; the ones that state nothing grant it, so this + * sentence is only ever the answer where a host said no. + */ +const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; + +/** A project this page can read: its remote is on a host with an implementation. */ +interface SupportedProject { + readonly project: OrchestrationProjectShell; + readonly api: PullRequestProviderApi; + readonly repository: string; + /** The host the repository lives on, which is the account boundary rather than the kind. */ + readonly host: string; +} + +/** + * What the workspace has, split by whether this build can read it. Hosts with no + * implementation are counted rather than dropped, so their projects are explained in the + * provider list instead of quietly missing from the page. + */ +interface WorkspaceProjects { + readonly supported: ReadonlyArray; + /** Keyed by host, as the readable ones are: an unimplemented host is its own switcher entry. */ + readonly unimplemented: ReadonlyMap< + string, + { readonly kind: SourceControlProviderKind; readonly projectCount: number } + >; + /** + * Every checkout on a host, including the ones the listing de-duplicated away. Asking who is + * signed in is a question about the host rather than about a repository, and any checkout can + * answer it — so a broken worktree is not allowed to take the host down with it just because + * it happened to be the one the listing kept. + */ + readonly viewerRoots: ReadonlyMap>; +} + +interface RepositoryBatch { + /** Which repository this slice came from, which is what a cursor for it is filed under. */ + readonly key: string; + readonly entries: ReadonlyArray; + readonly errors: ReadonlyArray; + readonly truncated: boolean; + readonly nextCursor: string | null; +} + +/** What the providers are told, plus the part only the service acts on. */ +interface ListCursor extends ProviderListCursor { + /** + * The rows already handed over at exactly `updatedBefore`. The next read asks for that instant + * inclusively, so these are what keeps it from sending them a second time. + */ + readonly seenAt: ReadonlyArray; +} + +/** + * A continuation as it travels through the page and back. Written out rather than encoded because + * it comes back from a client and has to be believed or refused on sight: everything a host is + * given is either a timestamp of this shape or a number of this length, which is what lets a + * provider drop it into a filter without checking it again. + */ +const LIST_CURSOR_PATTERN = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))\|(\d{1,9})\|(\d{1,9}(?:,\d{1,9})*)?$/; + +function parseListCursor(raw: string): ListCursor | null { + const match = LIST_CURSOR_PATTERN.exec(raw); + if (match === null) return null; + const seenAt = match[3]; + return { + updatedBefore: match[1]!, + delivered: Number(match[2]), + seenAt: seenAt === undefined ? [] : seenAt.split(",").map(Number), + }; +} + +/** + * How a listing tells two repositories apart. The host is part of it because the same + * `owner/repo` exists on github.com and on an Enterprise install, and they are two repositories. + */ +function listCursorKey(host: string, repository: string): string { + return `${host} ${repository.toLowerCase()}`; +} + +/** + * Where a repository carries on, worked out from the slice just handed over. The boundary is the + * instant of the oldest row in it: the next read asks for that instant and everything before it, + * and names the rows already sent at it so none of them arrives twice. + * + * The names carry over when the boundary has not moved. A slice that ends on the same instant it + * began on has to keep the earlier rows excluded as well as its own, or the read after it would + * hand them over again. + */ +function nextListCursor( + previous: ListCursor | undefined, + /** What the host handed over, before the rows already sent were dropped from it. */ + fetched: ReadonlyArray, + /** What is being sent on, which is what the count of delivered rows is about. */ + delivered: ReadonlyArray, + /** A provider may consume malformed offset-paged rows that never appear in `delivered`. */ + cursorAdvance = delivered.length, +): string | null { + // The host had nothing at all, so there is no row to carry on from — and repeating the cursor + // that produced the empty slice would ask the same question forever. + if (fetched.length === 0) return null; + // Taken from what the host answered rather than from what survived de-duplication: a slice can + // be entirely rows already sent — a hundred change requests touched in the same second is one + // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the + // walk on the instant it was stuck on, with everything older unreachable for good. + const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); + return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance); +} + +/** + * The same cursor against a boundary chosen elsewhere, which is what a slice read across several + * repositories at once needs: every repository in it is read up to the oldest row of the whole + * slice, including the ones that contributed nothing to it — their rows are simply all older, and + * a repository that carried on from its own oldest row would be right about where it stopped and + * silent about the ones that never appeared. + */ +function listCursorAt( + previous: ListCursor | undefined, + boundary: string, + /** This repository's own rows in the slice, before the ones already sent were dropped. */ + fetched: ReadonlyArray, + deliveredCount: number, +): string { + const seenAt = [ + ...(previous?.updatedBefore === boundary ? previous.seenAt : []), + ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number), + ]; + return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`; +} + +/** A host that cannot be read at all, as opposed to one request that failed. */ +function isProviderUnusable(error: PullRequestProviderError): boolean { + return error.reason === "missing-tool" || error.reason === "unauthenticated"; +} + +/** + * Why a host is not readable, told as the thing to do about it. A host that is simply not set up + * says so in the same words the whole-page state uses, rather than repeating whatever its tool + * printed — "HTTP 401" names the symptom, not the fix. + */ +function providerDetail(error: PullRequestProviderError): string { + if (!isProviderUnusable(error)) return error.detail; + return ( + pullRequestProviderRequirement( + error.provider, + error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + ) ?? error.detail + ); +} + +function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + cause: error, + }); +} + +function toPullRequestError( + operation: string, +): (error: PullRequestProviderError) => PullRequestError { + return (error) => + isProviderUnusable(error) + ? toUnavailableError(error) + : new PullRequestOperationError({ operation, detail: error.detail, cause: error }); +} + +/** + * The provider-native repository identity. `displayName` is the full path below the host, which + * is what nested GitLab groups and Azure project paths need; owner/name is the two-segment + * fallback for identities recorded before that field existed. + */ +function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { + const identity = project.repositoryIdentity; + if (!identity) return null; + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + +export const make = Effect.gen(function* () { + const registry = yield* PullRequestProviderRegistry; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const listWorkspaceProjects = ( + filter: Pick, + ): Effect.Effect => + projections.getShellSnapshot().pipe( + Effect.mapError( + (error) => + new PullRequestOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause: error, + }), + ), + Effect.map((snapshot) => { + const supported: SupportedProject[] = []; + const unimplemented = new Map< + string, + { kind: SourceControlProviderKind; projectCount: number } + >(); + const viewerRoots = new Map(); + const seen = new Set(); + for (const project of snapshot.projects) { + if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + const kind = project.repositoryIdentity?.provider as + | SourceControlProviderKind + | undefined; + const repository = repositoryIdentityOf(project); + if (kind === undefined || repository === null) continue; + // Worktrees of one repository are separate projects; reading the remote once keeps + // the page from repeating every change request per local checkout. The host is part + // of the key, so the same `owner/repo` on two hosts stays two repositories. + const host = pullRequestHostOf(project.repositoryIdentity, kind); + if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; + const api = registry.get(kind); + // Recorded before the de-duplication below, so the viewer lookup keeps the alternates + // the listing is about to drop. + if (api !== null) { + const roots = viewerRoots.get(host); + if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); + else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); + } + const key = listCursorKey(host, repository); + if (seen.has(key)) continue; + seen.add(key); + if (api === null) { + const counted = unimplemented.get(host); + if (counted === undefined) unimplemented.set(host, { kind, projectCount: 1 }); + else counted.projectCount += 1; + continue; + } + supported.push({ project, api, repository, host }); + } + return { supported, unimplemented, viewerRoots }; + }), + ); + + const requireProject = (ref: PullRequestRef): Effect.Effect => + listWorkspaceProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }): Effect.Effect => { + const match = supported[0]; + if (!match) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. + if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + return Effect.fail( + new PullRequestOperationError({ + operation: "resolveRepository", + detail: "The change request does not belong to the selected project.", + }), + ); + } + return Effect.succeed(match); + }), + ); + + /** + * What the signed-in account may do with this change request, asked of the host itself. Every + * write goes through it: the page hides what a viewer may not do, and a request that arrived + * without passing through the page — or after the access behind it was withdrawn — must not be + * handed to a provider on the client's word. Read freshly for that reason, rather than taken + * from whatever the detail said when the page loaded. + */ + const viewerPermissionsOf = (project: SupportedProject, ref: PullRequestRef, operation: string) => + project.api + .getViewerPermissions({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: ref.number, + }) + .pipe(Effect.mapError(toPullRequestError(operation))); + + /** + * The cursors the page sent back, read once before any host is asked anything. Null where the + * page sent none, which is the listing read from its newest row. + */ + const decodeCursors = ( + cursors: PullRequestListInput["cursors"], + ): Effect.Effect | null, PullRequestError> => { + if (cursors === undefined) return Effect.succeed(null); + const decoded = new Map(); + for (const [key, raw] of Object.entries(cursors)) { + const cursor = parseListCursor(raw); + if (cursor === null) { + return Effect.fail( + new PullRequestOperationError({ + operation: "list", + detail: "The list could not be carried on from where it left off.", + }), + ); + } + decoded.set(key, cursor); + } + return Effect.succeed(decoded); + }; + + /** + * One viewer lookup per host, tried across that host's workspaces so a single broken checkout + * cannot hide every healthy repository on it. Per host and not per provider kind: two GitHub + * hosts are two accounts, and the wrong login would misattribute every review request. + * + * Its failure doubles as the answer to "is this host set up", which is what the provider + * switcher shows. + */ + type ResolvedViewer = { + readonly host: string; + readonly kind: SourceControlProviderKind; + readonly viewer: string | null; + readonly error: PullRequestProviderError | null; + }; + // Who is signed in moves on the timescale of `gh auth login`, not of a page visit, yet every + // list read was asking each host's CLI again — a subprocess and a network round trip per host + // per read, three reads per page. Only a success is believed for a while: a failure is the + // "is this host set up" answer the provider switcher shows, and holding it would keep saying + // signed-out after the reader has signed in. + const viewersByHost = new Map(); + + const resolveViewers = ( + projects: ReadonlyArray, + viewerRoots: WorkspaceProjects["viewerRoots"], + ) => + Effect.forEach( + [...new Set(projects.map(({ host }) => host))], + (host) => + Effect.flatMap(Clock.currentTimeMillis, (now): Effect.Effect => { + const held = viewersByHost.get(host); + if (held !== undefined && now - held.at <= Duration.toMillis(VIEWER_CACHE_TTL)) { + return Effect.succeed(held.result); + } + const forHost = projects.filter((project) => project.host === host); + const api = forHost[0]!.api; + // Every checkout on the host, not just the ones that survived de-duplication: one + // unreadable worktree would otherwise report the whole host as signed out. + const roots = + viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); + return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( + Effect.map((viewer) => ({ + host, + kind: api.kind, + viewer: viewer as string | null, + error: null as PullRequestProviderError | null, + })), + Effect.tap((result) => + Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), + ), + Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), + ); + }), + { concurrency: REPOSITORY_CONCURRENCY }, + ); + + const toEntry = (input: { + readonly project: SupportedProject; + readonly item: ProviderChangeRequest; + readonly viewer: string; + }): PullRequestListEntry => { + const viewer = input.viewer.toLowerCase(); + return { + provider: input.project.api.kind, + host: input.project.host, + projectId: input.project.project.id, + projectTitle: input.project.project.title, + repository: input.project.repository, + number: input.item.number, + title: input.item.title, + url: input.item.url, + author: input.item.author, + headBranch: input.item.headBranch, + baseBranch: input.item.baseBranch, + state: input.item.state, + isDraft: input.item.isDraft, + mergeability: input.item.mergeability, + additions: input.item.additions, + deletions: input.item.deletions, + createdAt: input.item.createdAt, + updatedAt: input.item.updatedAt, + viewerReviewRequested: + input.item.author?.login.toLowerCase() !== viewer && + input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), + labels: input.item.labels, + }; + }; + + const listUncached: PullRequestService["Service"]["list"] = (input) => + Effect.gen(function* () { + const involvement = input.involvement ?? "all"; + // Refused whole rather than per repository: a cursor is only ever a value this service + // issued, so one that does not read as one means the page is sending something it made up, + // and reading part of the listing under that assumption would quietly lose rows. + const continuation = yield* decodeCursors(input.cursors); + const { + supported: projects, + unimplemented, + viewerRoots, + } = yield* listWorkspaceProjects(input); + const projectCounts = new Map(); + for (const { host } of projects) { + projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); + } + + const viewerResults = yield* resolveViewers(projects, viewerRoots); + const viewers: Record = {}; + for (const result of viewerResults) { + if (result.viewer !== null) viewers[result.host] = result.viewer; + } + + // One summary per host, which is what the viewer lookup already answers for: two GitHub + // hosts sign in separately, so collapsing them by kind would report one as the other. + const providers: ReadonlyArray = [ + ...viewerResults.map((result) => ({ + host: result.host, + kind: result.kind, + searchesOnHost: + projects.find((project) => project.host === result.host)?.api.capabilities.search ?? + false, + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + })), + ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ + host, + kind, + searchesOnHost: false, + projectCount, + configured: false, + detail: "This host cannot be browsed here yet.", + })), + ]; + + // A continued listing reads only the repositories it was asked to carry on with: every + // other one is already on the page, and reading it again is the whole cost this is here to + // avoid. The host summaries above stay over the whole workspace, because the switcher they + // fill is about the workspace rather than about this slice. + const selected = + continuation === null + ? projects + : projects.filter(({ host, repository }) => + continuation.has(listCursorKey(host, repository)), + ); + const readable = selected.filter(({ host }) => viewers[host] !== undefined); + // A host that could not be read still has projects, and they are absent from the list. + // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. + const unreadable = selected + .filter(({ host }) => viewers[host] === undefined) + .map(({ project, repository }) => ({ + projectId: project.id, + projectTitle: project.title, + message: `${repository} could not be read.`, + })); + if (readable.length === 0) { + // No host this request covers can be read, so it is not a per-project problem. An + // unusable host is preferred as the reported cause because it names the fix; a host + // that merely failed reports as a failed operation rather than as a signed-out CLI, + // which would send the reader to `auth login` over a transient error. + // + // Only the hosts this request was actually going to read: a continuation that named + // nothing has asked for nothing, and a host it never mentioned being signed out is no + // reason to refuse it. + const errors = viewerResults.flatMap((result) => + result.error === null || !selected.some(({ host }) => host === result.host) + ? [] + : [result.error], + ); + const blocking = errors.find(isProviderUnusable) ?? errors[0]; + if (blocking) { + return yield* toPullRequestError("list")(blocking); + } + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + }; + } + + const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; + const cursorOf = (project: SupportedProject): ListCursor | undefined => + continuation?.get(listCursorKey(project.host, project.repository)); + + /** + * One repository asked on its own. What every host without a search across repositories + * does, and what a batched read falls back to for a repository it could not answer for. + */ + const readRepository = (project: SupportedProject): Effect.Effect => { + { + const viewer = viewers[project.host]!; + const key = listCursorKey(project.host, project.repository); + const cursor = cursorOf(project); + return project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + state: input.state, + involvement, + viewer, + limit, + // Each host matches this its own way, and one that cannot match text at all + // answers unnarrowed rather than failing. + query: input.query, + // Only the two fields a host can act on: which rows have already been sent at the + // boundary instant is this service's business, not a provider's. + ...(cursor === undefined + ? {} + : { + cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered }, + }), + }) + .pipe( + Effect.map((page): RepositoryBatch => { + // The boundary instant was asked for inclusively, so the rows already sent at it + // come back with the slice. Dropping them here rather than asking for strictly + // older is what keeps their neighbours at the same instant from being skipped. + const items = + cursor === undefined + ? page.items + : page.items.filter( + (item) => + item.updatedAt !== cursor.updatedBefore || + !cursor.seenAt.includes(item.number), + ); + return { + key, + entries: items.map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.continues && page.truncated + ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + : null, + }; + }), + // One unreachable repository must not blank the page. A host-level failure is + // already reported through `providers`, so it degrades the same way here. + Effect.orElseSucceed( + (): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + }), + ), + ); + } + }; + + /** + * One host's repositories in one read. The slice is the newest `limit` rows across all of + * them, so it is split back up by repository here: the page still reports per project, and + * each repository still carries on from a cursor of its own. + * + * A read that fails is read the long way instead. The batch is an optimisation, and a host + * that could not answer one question about twelve repositories should not report twelve + * repositories as unreadable before anyone has asked it about them one at a time. + */ + const readTogether = ( + chunk: ReadonlyArray, + ): Effect.Effect> => { + const first = chunk[0]!; + const readAcross = first.api.listChangeRequestsAcross; + const separately = () => + Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); + if (readAcross === undefined) return separately(); + const viewer = viewers[first.host]!; + const cursor = cursorOf(first); + return readAcross({ + cwd: first.project.workspaceRoot, + host: first.host, + repositories: chunk.map((project) => project.repository), + state: input.state, + involvement, + viewer, + limit, + query: input.query, + ...(cursor === undefined + ? {} + : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), + }).pipe( + Effect.flatMap((page) => { + const rows = new Map>(); + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + } + // The oldest row of the whole slice, which is how far every repository in it has now + // been read — including the ones that contributed nothing to it. + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; + // GitHub does not index every repository for search — a renamed one answers for + // its old name with silence rather than with an error — so a repository the + // search said nothing at all about is read on its own, once, before it is + // believed. Only on its first slice: after that it has a boundary to carry on + // from, and silence past one means the rows are older rather than absent. That + // keeps a search-invisible repository from disappearing on a busy host, at the + // price of one request per repository with nothing in the first slice — which + // run together, and only there. + if (fetched.length === 0 && cursorOf(project) === undefined) { + return readRepository(project); + } + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: listCursorKey(project.host, project.repository), + entries: items.map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), + Effect.catch(separately), + ); + }; + + // A host with a search across repositories is asked once for all of them; everyone else is + // asked once each. Repositories standing at different points of the same listing are + // different questions, so they are grouped by the boundary they carry on from. + const together = new Map>(); + const separate: Array = []; + for (const project of readable) { + if (project.api.listChangeRequestsAcross === undefined) { + separate.push(project); + continue; + } + const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; + const group = together.get(key); + if (group === undefined) together.set(key, [project]); + else group.push(project); + } + const reads: Array>> = separate.map((project) => + readRepository(project).pipe(Effect.map((batch) => [batch])), + ); + for (const group of together.values()) { + for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { + reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); + } + } + const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); + + const nextCursors: Record = {}; + for (const batch of batches) { + if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; + } + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + truncated: batches.some((batch) => batch.truncated), + nextCursors, + }; + }); + + const detailUncached: PullRequestService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe( + Effect.mapError(toPullRequestError("detail")), + Effect.map( + (changeRequest): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + comments: changeRequest.comments, + commentCount: changeRequest.commentCount, + commentsTruncated: changeRequest.commentsTruncated, + reviewThreads: changeRequest.reviewThreads, + commits: changeRequest.commits, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + }), + ), + ), + ), + ); + + const diffUncached: PullRequestService["Service"]["diff"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.capabilities.diff + ? project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diff"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diff", + detail: "This host cannot provide a diff for a change request.", + }), + ), + ), + ); + + const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getDiffFileContents; + return project.api.capabilities.diff && read + ? read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + changeType: input.changeType, + oldPath: input.oldPath, + newPath: input.newPath, + }).pipe(Effect.mapError(toPullRequestError("diffFileContents"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diffFileContents", + detail: "This host cannot expand unchanged pull request lines.", + }), + ); + }), + ); + + const runAction: PullRequestService["Service"]["runAction"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + // The surface hides what a host cannot do, and this refuses it as well: a request that + // reached here anyway must not be handed to a provider that never claimed the action. + if (!project.api.capabilities.actions.includes(input.action)) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot ${input.action} a change request.`, + }), + ); + } + // A strategy the host does not offer must be refused rather than passed on: every + // provider maps an unrecognised method to its own default, so asking Azure DevOps to + // rebase would quietly merge instead of failing. + if ( + input.mergeMethod !== undefined && + !project.api.capabilities.mergeMethods.includes(input.mergeMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot merge with the ${input.mergeMethod} strategy.`, + }), + ); + } + // What the host can do and what this account may ask of it are two questions, and both + // have to say yes. The second is asked last, because it costs a request and the checks + // above do not. + return viewerPermissionsOf(project, input, "runAction").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.actions.includes(input.action)) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS[input.action], + }), + ); + } + return project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(toPullRequestError("runAction"))); + }), + ); + }), + ); + + const comment: PullRequestService["Service"]["comment"] = (input) => + // The contract keeps the body verbatim because it is markdown, so the "did the user + // actually write something" check lives here. + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: "A comment cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: "This host cannot post a comment on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "comment").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: + "You need write access on this repository to comment on a change request.", + }), + ); + } + return project.api + .comment({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("comment"))); + }), + ); + }), + ); + + const submitReview: PullRequestService["Service"]["submitReview"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const review = project.api.capabilities.review; + const refuse = (detail: string) => + Effect.fail(new PullRequestOperationError({ operation: "submitReview", detail })); + // The surface hides what a host cannot do, and this refuses it as well: a request that + // reached here anyway must not be handed to a provider that never claimed it. + if (!review.verdicts.includes(input.verdict)) { + return refuse(`This host cannot ${VERDICT_LABELS[input.verdict]} a change request.`); + } + if (input.comments.length > 0 && !review.inlineComment) { + return refuse("This host cannot comment on a line of a change request."); + } + // A verdict with nothing attached to it is a request every host rejects, and doing so + // here says which of the two is missing rather than reporting the host's refusal. + if ( + input.verdict !== "approve" && + input.body.trim().length === 0 && + input.comments.length === 0 + ) { + return refuse("A review needs a summary or at least one comment."); + } + return viewerPermissionsOf(project, input, "submitReview").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.verdicts.includes(input.verdict)) { + return refuse( + `You need write access on this repository to ${ + VERDICT_LABELS[input.verdict] + } a change request.`, + ); + } + if (input.comments.length > 0 && !viewer.comment) { + return refuse( + "You need write access on this repository to comment on a line of a change request.", + ); + } + return project.api + .submitReview({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(toPullRequestError("submitReview"))); + }), + ); + }), + ); + + const replyToThread: PullRequestService["Service"]["replyToThread"] = (input) => + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: "A reply cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.review.reply) { + return Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: "This host cannot reply to a review conversation.", + }), + ); + } + return viewerPermissionsOf(project, input, "replyToThread").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: + "You need write access on this repository to reply to a review conversation.", + }), + ); + } + return project.api + .replyToThread({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("replyToThread"))); + }), + ); + }), + ); + + const setThreadResolution: PullRequestService["Service"]["setThreadResolution"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.review.resolve) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setThreadResolution", + detail: "This host cannot resolve a review conversation.", + }), + ); + } + return viewerPermissionsOf(project, input, "setThreadResolution").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.resolve) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setThreadResolution", + detail: + "You need write access on this repository, or to have opened this change request, to resolve a review conversation.", + }), + ); + } + return project.api + .setThreadResolution({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(toPullRequestError("setThreadResolution"))); + }), + ); + }), + ); + + /** + * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is + * the one the request is made from. So the same permission guards both: a page that could open + * the menu without it would offer a list whose every press was going to be turned down. + */ + const reviewerCandidates: PullRequestService["Service"]["reviewerCandidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap( + (project): Effect.Effect => { + if (!project.api.capabilities.reviewers.listCandidates) { + return Effect.fail( + new PullRequestOperationError({ + operation: "reviewerCandidates", + detail: "This host cannot say who may review a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "reviewerCandidates").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.requestReviewers + ? project.api + .listReviewerCandidates({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("reviewerCandidates"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "reviewerCandidates", + detail: REVIEWER_REQUEST_REFUSAL, + }), + ), + ), + ); + }, + ), + ); + + const requestReviewers: PullRequestService["Service"]["requestReviewers"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.reviewers.request) { + return Effect.fail( + new PullRequestOperationError({ + operation: "requestReviewers", + detail: "This host cannot ask somebody for a review.", + }), + ); + } + return viewerPermissionsOf(project, input, "requestReviewers").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.requestReviewers) { + return Effect.fail( + new PullRequestOperationError({ + operation: "requestReviewers", + detail: REVIEWER_REQUEST_REFUSAL, + }), + ); + } + return project.api + .setReviewerRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(toPullRequestError("requestReviewers"))); + }), + ); + }), + ); + + /** + * The line counts for rows already on the page, which the listing left out because on GitHub + * they cost more than everything else on the row put together. + * + * One read per host rather than per row, and only for a host whose listing defers them; a row + * whose host answered with the counts in the first place is not here to be asked about. A ref + * that names no project this workspace has, or a repository that is not the one the project's + * remote points at, is dropped rather than refused: it is one row's two numbers, and the page + * that asked has already moved on. + */ + const listStatsUncached: PullRequestService["Service"]["listStats"] = (input) => + Effect.gen(function* () { + if (input.refs.length === 0) return { stats: [] }; + const { supported } = yield* listWorkspaceProjects({}); + const byProject = new Map(supported.map((project) => [project.project.id, project])); + const wanted = new Map< + string, + { readonly project: SupportedProject; readonly number: number } + >(); + for (const ref of input.refs) { + const project = byProject.get(ref.projectId); + // The repository travels through the client, so it is checked against the project's own + // remote rather than being handed to a provider verbatim. + if ( + project === undefined || + project.api.listChangeRequestStats === undefined || + project.repository.toLowerCase() !== ref.repository.trim().toLowerCase() + ) { + continue; + } + wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); + } + const byHost = new Map>(); + for (const entry of wanted.values()) { + const held = byHost.get(entry.project.host); + if (held === undefined) byHost.set(entry.project.host, [entry]); + else held.push(entry); + } + const stats = yield* Effect.forEach( + [...byHost.values()], + (entries) => { + const first = entries[0]!; + const readStats = first.project.api.listChangeRequestStats; + if (readStats === undefined) + return Effect.succeed>([]); + const projectsByRepository = new Map( + entries.map((entry) => [ + `${entry.project.repository.toLowerCase()} ${entry.number}`, + entry.project, + ]), + ); + return readStats({ + cwd: first.project.project.workspaceRoot, + host: first.project.host, + changeRequests: entries.map((entry) => ({ + repository: entry.project.repository, + number: entry.number, + })), + }).pipe( + Effect.map((read) => + read.flatMap((stat): ReadonlyArray => { + const project = projectsByRepository.get( + `${stat.repository.toLowerCase()} ${stat.number}`, + ); + return project === undefined + ? [] + : [ + { + projectId: project.project.id, + repository: project.repository, + number: stat.number, + additions: stat.additions, + deletions: stat.deletions, + }, + ]; + }), + ), + // A row without its counts is a row the page already draws without them, so a host + // that could not answer costs the numbers rather than the answer. + Effect.orElseSucceed((): ReadonlyArray => []), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + return { stats: stats.flat() }; + }); + + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + /** + * Stale answers served while a fresh one is fetched behind them. Every read here leaves the + * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a + * slow network — and the short cache windows below mean almost every page visit pays that + * clock again. The last success per key is therefore held a while longer: a read inside the + * window answers with it at once and refreshes the cache in the background, so the next read + * is fresh without anyone having waited on it. + * + * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is + * part of every key, and a held answer under the old key is simply never asked for again — so + * "give me truly fresh" still means exactly that. + */ + const staleWhileRevalidate = (staleFor: Duration.Duration, capacity: number) => { + const staleMs = Duration.toMillis(staleFor); + const held = new Map(); + const record = (key: string, value: A) => + Effect.map(Clock.currentTimeMillis, (at) => { + held.delete(key); + if (held.size >= capacity) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, { at, value }); + }); + return (key: string, read: Effect.Effect): Effect.Effect => { + const recorded = read.pipe(Effect.tap((value) => record(key, value))); + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const snapshot = held.get(key); + if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; + // Run as its own fiber rather than a child: the caller is answered and gone before the + // refresh lands. The read still coalesces on the cache key, so ten stale reads in one + // window cost one host request — and a failed refresh costs nothing but the retry. + return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); + }); + }; + }; + + // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the + // epoch strands every entry made under the old one — no enumerating a cache whose keys + // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a + // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. + let epochCounter = 0; + let listingsEpoch = 0; + const refEpochs = new Map(); + const REF_EPOCH_CAPACITY = 2_048; + const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; + const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + const bumpRefEpoch = (ref: PullRequestRef) => { + const scope = refScope(ref); + if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { + const oldest = refEpochs.keys().next().value; + if (oldest !== undefined) refEpochs.delete(oldest); + } + refEpochs.set(scope, ++epochCounter); + }; + + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder + // of in-flight state: concurrent identical reads coalesce on the key into one host request. + // The continuation cursors are part of the key, entries sorted so one continuation is one + // key however its record was assembled — a further slice is its own answer, cached like any. + const listCache = yield* Cache.makeWith( + (key: string) => { + // The parse undoes this module's own serialization, so the shapes are known exactly; + // the cast restores the branded field types JSON cannot carry. + const [, state, involvement, projectId, host, limit, query, cursorEntries] = JSON.parse( + key, + ) as [ + number, + string, + string | null, + string | null, + string | null, + number | null, + string | null, + ReadonlyArray<[string, string]> | null, + ]; + return listUncached({ + state, + ...(involvement === null ? {} : { involvement }), + ...(projectId === null ? {} : { projectId }), + ...(host === null ? {} : { host }), + ...(limit === null ? {} : { limit }), + ...(query === null ? {} : { query }), + ...(cursorEntries === null ? {} : { cursors: Object.fromEntries(cursorEntries) }), + } as PullRequestListInput); + }, + { + capacity: LIST_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), + }, + ); + const staleList = staleWhileRevalidate( + LIST_STALE_WINDOW, + LIST_CACHE_CAPACITY, + ); + const list: PullRequestService["Service"]["list"] = (input) => { + const key = JSON.stringify([ + listingsEpoch, + input.state, + input.involvement ?? null, + input.projectId ?? null, + input.host ?? null, + input.limit ?? null, + input.query ?? null, + input.cursors === undefined + ? null + : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), + ]); + return staleList(key, Cache.get(listCache, key)); + }; + + const detailCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return detailUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), + }, + ); + const staleDetail = staleWhileRevalidate( + DETAIL_STALE_WINDOW, + DETAIL_CACHE_CAPACITY, + ); + const detail: PullRequestService["Service"]["detail"] = (input) => { + const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + return staleDetail(key, Cache.get(detailCache, key)); + }; + + const diffCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ + number, + string, + string, + number, + string | null, + string | null, + ]; + return diffUncached({ + projectId, + repository, + number, + ...(cursor === null ? {} : { cursor }), + ...(commit === null ? {} : { commit }), + } as PullRequestDiffInput); + }, + { + capacity: DIFF_CACHE_CAPACITY, + timeToLive: (exit, key) => { + if (!Exit.isSuccess(exit)) return Duration.zero; + const commit = (JSON.parse(key) as ReadonlyArray)[5]; + return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; + }, + }, + ); + const staleDiff = staleWhileRevalidate( + DIFF_STALE_WINDOW, + DIFF_CACHE_CAPACITY, + ); + const diff: PullRequestService["Service"]["diff"] = (input) => { + const key = JSON.stringify([ + refEpoch(input), + input.projectId, + input.repository, + input.number, + input.cursor ?? null, + input.commit ?? null, + ]); + return staleDiff(key, Cache.get(diffCache, key)); + }; + + const listStatsCache = yield* Cache.makeWith( + (key: string) => { + const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number]>]; + return listStatsUncached({ + refs: refs.map(([projectId, repository, number]) => ({ projectId, repository, number })), + } as unknown as PullRequestListStatsInput); + }, + { + capacity: LIST_STATS_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_STATS_CACHE_TTL : Duration.zero), + }, + ); + // The stats read leans on the host's search API — the scarcest limit of them all — so it + // shares between clients like every other read. Refs are sorted so one page's worth of rows + // is one key however the client assembled them, and the listings epoch rides along so the + // refresh that forgets the listing forgets its decorations with it. + const staleListStats = staleWhileRevalidate( + LIST_STALE_WINDOW, + LIST_STATS_CACHE_CAPACITY, + ); + const listStats: PullRequestService["Service"]["listStats"] = (input) => { + if (input.refs.length === 0) return Effect.succeed({ stats: [] }); + const key = JSON.stringify([ + listingsEpoch, + input.refs + .map((ref) => [ref.projectId, ref.repository, ref.number] as const) + .toSorted((left, right) => + `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), + ), + ]); + return staleListStats(key, Cache.get(listStatsCache, key)); + }; + + const invalidate: PullRequestService["Service"]["invalidate"] = (input) => + Effect.sync(() => { + if (input.reference === undefined) { + listingsEpoch = ++epochCounter; + // A whole-workspace refresh is the reader asking to be re-answered from the hosts, + // and that includes who the hosts say they are. + viewersByHost.clear(); + return; + } + bumpRefEpoch(input.reference); + }); + + // A mutation's own client re-reads right after it, and every other client's next read must + // see the action too — so a write forgets the change request it touched and the listings its + // state change reorders, for everyone, without any client asking. + const invalidatedByMutation = + ( + method: (input: I) => Effect.Effect, + ): ((input: I) => Effect.Effect) => + (input) => + method(input).pipe( + Effect.tap(() => + Effect.sync(() => { + bumpRefEpoch(input); + listingsEpoch = ++epochCounter; + }), + ), + ); + + return PullRequestService.of({ + list, + listStats, + detail, + diff, + diffFileContents, + runAction: invalidatedByMutation(runAction), + comment: invalidatedByMutation(comment), + submitReview: invalidatedByMutation(submitReview), + replyToThread: invalidatedByMutation(replyToThread), + setThreadResolution: invalidatedByMutation(setThreadResolution), + // The candidate list is deliberately read fresh per menu-open, so it stays uncached. + reviewerCandidates, + requestReviewers: invalidatedByMutation(requestReviewers), + invalidate, + }); +}); + +export const layer = Layer.effect(PullRequestService, make); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts new file mode 100644 index 00000000000..3ac55cde1e8 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -0,0 +1,300 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, +} from "./azureDevOpsPullRequestJson.ts"; + +const REST_URL = + "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; + +/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + pullRequestId: 42, + title: "Add the change requests page", + description: "Ships the page.", + status: "active", + isDraft: false, + mergeStatus: "succeeded", + createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: REST_URL, + repository: { name: "web", project: { name: "platform" } }, + ...overrides, + }; +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +const asJson = (value: unknown) => JSON.stringify(value); + +describe("decodePullRequestListJson", () => { + it("reads a pull request as a change request", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + title: "Add the change requests page", + // The login is an email, because that is what `az account show` reports to compare with. + author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, + // Azure prefixes its refs, which no other host does. + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + }); + }); + + it("assembles a browser url when Azure reports no web link", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it("prefers the web link Azure sends when asked for one", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([ + pullRequest({ + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ]), + ), + ); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it.each([ + ["active", "open"], + ["completed", "merged"], + ["abandoned", "closed"], + ["something new", "open"], + ])("reads the %s status as %s", (status, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); + + expect(batch.items[0]?.state).toBe(expected); + }); + + it.each([ + ["succeeded", "mergeable"], + ["conflicts", "conflicting"], + ["rejectedByPolicy", "conflicting"], + ["queued", "unknown"], + ["notSet", "unknown"], + ])("reads the %s merge status as %s", (mergeStatus, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); + + expect(batch.items[0]?.mergeability).toBe(expected); + }); + + it("stands the closing time in for a last-touched time Azure does not keep", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), + ), + ); + + expect(batch.items[0]).toMatchObject({ + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + }); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + expect(batch.rawIndexes).toEqual([1]); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], + }), + ), + ), + ); + + expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); + expect(detail?.reviewers).toEqual([ + { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, + ]); + }); + + it("works out where the conversation lives from what Azure returned", () => { + const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); + + expect(detail?.threadsUrl).toBe( + "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads", + ); + }); + + it("reports no conversation url when Azure said too little to build one", () => { + // A web link places the pull request, but without the REST url and repository there is + // nothing to hang a threads collection off. + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + url: null, + repository: null, + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ), + ), + ); + + expect(detail?.threadsUrl).toBeNull(); + }); + + it("returns nothing when Azure gave no way to place the pull request at all", () => { + const detail = expectSuccess( + decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), + ); + + expect(detail).toBeNull(); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in account name", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( + "bilal@acme.dev", + ); + }); + + it("returns nothing when nobody is signed in", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); + }); +}); + +describe("decodeThreadsJson", () => { + it("takes every real comment of every thread, oldest first", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 2, + comments: [ + { + id: 1, + content: "Second remark.", + author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, + publishedDate: "2026-07-03T00:00:00Z", + }, + ], + }, + { + id: 1, + comments: [ + // Azure's own activity notes are events rather than remarks. + { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, + { + id: 2, + content: "First remark.", + author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, + publishedDate: "2026-07-02T00:00:00Z", + }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); + expect(comments[0]).toMatchObject({ + kind: "issue-comment", + author: { login: "bilal@acme.dev" }, + }); + }); + + it("reads a thread pinned to a file as a review comment", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 3, + threadContext: { filePath: "/src/app.ts" }, + comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); + }); + + it("keeps the replies under a thread, which are as much of the conversation", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 4, + threadContext: { filePath: "/src/app.ts" }, + comments: [ + { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, + { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, + { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); + }); + + it("drops deleted threads and threads with nothing to show", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 1, + isDeleted: true, + comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], + }, + { id: 2, comments: [] }, + { + id: 3, + comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments).toEqual([]); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts new file mode 100644 index 00000000000..a51eef4f0ce --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -0,0 +1,333 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestComment, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { + azureDevOpsOrganizationBaseFromRestApiUrl, + azureDevOpsPullRequestWebUrl, +} from "../sourceControl/azureDevOpsPullRequests.ts"; + +/** + * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as + * the other hosts: a new merge status must not fail a whole payload. Every field beyond the + * identity is optional, because `az repos pr` returns rather more or less of the REST object + * depending on the command. + */ +const RawIdentitySchema = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + /** An email or UPN, which is what `az account show` reports for the signed-in user. */ + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestSchema = Schema.Struct({ + pullRequestId: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), + createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), + // Required, and required to be non-empty: the wire contract will not carry a change request + // without a branch or a created time, so a row missing one is skipped rather than breaking the + // response it travels in. + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + creationDate: TrimmedNonEmptyString, + closedDate: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + webUrl: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + ), + _links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + web: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** A pull request thread, which is how Azure keeps its conversation. */ +const RawThreadSchema = Schema.Struct({ + id: Schema.Int, + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + threadContext: Schema.optional( + Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + comments: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Int)), + content: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawIdentitySchema)), + publishedDate: Schema.optional(Schema.NullOr(Schema.String)), + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** `system` marks the notes Azure writes itself, which are events, not comments. */ + commentType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), +}); + +const RawThreadPageSchema = Schema.Struct({ + value: Schema.Array(Schema.Unknown), +}); + +const RawViewerSchema = Schema.Struct({ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +export interface AzureDevOpsPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + /** + * Azure records no last-touched time on a pull request, so the closing time stands in where + * there is one and the creation time otherwise. The same fallback the rest of the app uses. + */ + readonly updatedAt: string; + readonly closedAt: string | null; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** Where this pull request's threads live, when Azure said enough to work it out. */ + readonly threadsUrl: string | null; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +/** A login has to compare against `az account show`, which reports an email. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); + return login === null + ? null + : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.status?.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toLowerCase()) { + case "succeeded": + return "mergeable"; + case "conflicts": + case "failure": + case "rejectedbypolicy": + return "conflicting"; + default: + // `queued` and `notSet` mean Azure has not finished checking. + return "unknown"; + } +} + +/** + * The REST collection a pull request's threads hang from. Built from what Azure returned rather + * than from the local remote, whose shape differs between the modern, legacy and SSH forms. + */ +function toThreadsUrl(raw: Schema.Schema.Type): string | null { + const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); + const project = trimmed(raw.repository?.project?.name); + const repository = trimmed(raw.repository?.name); + if (base === null || project === null || repository === null) return null; + return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; +} + +/** + * Null when Azure said too little to place the pull request: a row with no browser url and no + * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract + * refuses to carry it either. + */ +function toPullRequest( + raw: Schema.Schema.Type, +): AzureDevOpsPullRequest | null { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + const closedAt = trimmed(raw.closedDate); + const url = trimmed( + azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }), + ); + const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); + const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); + if (url === null || headBranch === null || baseBranch === null) return null; + return { + number: raw.pullRequestId, + title: raw.title, + url, + author: toActor(raw.createdBy), + headBranch, + baseBranch, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeStatus), + createdAt: raw.creationDate, + updatedAt: closedAt ?? raw.creationDate, + closedAt, + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + threadsUrl: toThreadsUrl(raw), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); +const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); + +type DecodeFailure = Cause.Cause; + +export interface AzureDevOpsPullRequestBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in Azure's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: AzureDevOpsPullRequest[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodePullRequestEntry(entry); + if (Exit.isFailure(item)) continue; + const pullRequest = toPullRequest(item.value); + if (pullRequest !== null) { + items.push(pullRequest); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +/** Null carries "Azure answered, but with too little to use", which the caller reports. */ +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +/** `az account show --query user` reports the signed-in account, whose name is an email. */ +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.user?.name)) + : Result.fail(decoded.failure); +} + +/** + * Azure keeps its conversation as threads of comments, and every one of them is a remark + * somebody wrote: a reply under a thread is as much of the conversation as the line that opened + * it. A thread pinned to a file is a line-level review comment. + * + * Azure answers the whole thread collection in one response, with no cursor and no page to + * follow, so what this returns is everything the host has. + */ +export function decodeThreadsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeThreadPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success.value) { + const decodedThread = decodeThreadEntry(entry); + if (Exit.isFailure(decodedThread)) continue; + const thread = decodedThread.value; + if (thread.isDeleted === true) continue; + const path = trimmed(thread.threadContext?.filePath); + for (const comment of thread.comments ?? []) { + const publishedDate = trimmed(comment.publishedDate); + if ( + comment.isDeleted === true || + comment.commentType?.trim().toLowerCase() === "system" || + (comment.content ?? "").trim().length === 0 || + publishedDate === null + ) { + continue; + } + comments.push({ + id: `${thread.id}:${comment.id ?? 0}`, + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.author), + body: comment.content ?? "", + createdAt: publishedDate, + url: null, + path, + reviewState: null, + }); + } + } + return Result.succeed( + comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ); +} diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts new file mode 100644 index 00000000000..81212949fdb --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -0,0 +1,360 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, +} from "./bitbucketPullRequestJson.ts"; + +/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + id: 897, + title: "Add trustabl-pipe", + description: "# Add trustabl-pipe", + state: "OPEN", + draft: false, + created_on: "2026-06-16T05:04:32.258456+00:00", + updated_on: "2026-06-16T05:04:33.750542+00:00", + author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, + ...overrides, + }; +} + +function page(values: ReadonlyArray, extra: Record = {}): string { + return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodePullRequestPageJson", () => { + it("reads a pull request as a change request", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items).toHaveLength(1); + expect(decoded.items[0]).toMatchObject({ + number: 897, + title: "Add trustabl-pipe", + url: "https://bitbucket.org/acme/web/pull-requests/897", + author: { login: "bilal", name: "Bilal Hassan" }, + headBranch: "feat/page", + baseBranch: "master", + state: "open", + isDraft: false, + // Bitbucket says nothing about conflicts on the pull request itself. + mergeability: "unknown", + }); + expect(decoded.next).toBeNull(); + }); + + it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items[0]).toMatchObject({ + createdAt: "2026-06-16T05:04:32.258Z", + updatedAt: "2026-06-16T05:04:33.750Z", + }); + }); + + it("reports the next page as the whole URL Bitbucket sends", () => { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); + + expect(decoded.next).toBe(next); + }); + + it.each([ + ["MERGED", "merged"], + ["DECLINED", "closed"], + ["SUPERSEDED", "closed"], + ["OPEN", "open"], + ["something new", "open"], + ])("reads the %s state as %s", (state, expected) => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); + + expect(decoded.items[0]?.state).toBe(expected); + }); + + it("skips a malformed row rather than failing the page", () => { + const decoded = expectSuccess( + decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), + ); + + expect(decoded.items).toHaveLength(1); + }); + + it("fails when Bitbucket did not answer with a page", () => { + expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( + true, + ); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + reviewers: [{ nickname: "julius", display_name: "Julius" }], + }), + ), + ), + ); + + expect(decoded.reviewRequestLogins).toEqual(["julius"]); + expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + }); + + it("reads a participant's vote as a review", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + participants: [ + { + user: { nickname: "julius", display_name: "Julius" }, + role: "REVIEWER", + approved: true, + state: "approved", + participated_on: "2026-06-17T09:00:00+00:00", + }, + // Added as a reviewer but has not voted, so there is no verdict to show. + { + user: { nickname: "sam", display_name: "Sam" }, + role: "REVIEWER", + approved: false, + state: null, + participated_on: null, + }, + ], + }), + ), + ), + ); + + expect(decoded.reviews).toHaveLength(1); + expect(decoded.reviews[0]).toMatchObject({ + kind: "review", + author: { login: "julius" }, + reviewState: "approved", + createdAt: "2026-06-17T09:00:00.000Z", + }); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in nickname", () => { + const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); + + expect(expectSuccess(decoded)).toBe("bilal"); + }); + + it("falls back to the display name, which app accounts have instead", () => { + const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); + + expect(expectSuccess(decoded)).toBe("Release Bot"); + }); + + it("returns nothing when the account has neither", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); + }); +}); + +describe("decodeCommentsJson", () => { + it("keeps a posted comment and drops deleted and unposted ones", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 797230941, + content: { raw: "The issue is ready for review." }, + user: { display_name: "Release Bot", type: "app_user" }, + created_on: "2026-05-15T01:58:38.220690+00:00", + deleted: false, + pending: false, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, + }, + { + id: 2, + content: { raw: "gone" }, + created_on: "2026-05-15T02:00:00+00:00", + deleted: true, + }, + { + id: 3, + content: { raw: "wip" }, + created_on: "2026-05-15T02:00:00+00:00", + pending: true, + }, + { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, + ]), + ), + ); + + expect(decoded.comments).toHaveLength(1); + expect(decoded.comments[0]).toMatchObject({ + id: "797230941", + kind: "issue-comment", + // An app account has no nickname, so its display name is the only handle it has. + author: { login: "Release Bot" }, + createdAt: "2026-05-15T01:58:38.220Z", + }); + }); + + it("reads a comment pinned to a file as a review comment", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 5, + content: { raw: "Rename this." }, + created_on: "2026-05-15T02:00:00+00:00", + inline: { path: "src/app.ts" }, + }, + ]), + ), + ); + + expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first with only the subject line", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, + { + hash: "aaa", + message: "first\n", + date: "2026-06-16T04:50:49+00:00", + author: { + raw: "Ada Lovelace ", + user: { nickname: "ada", display_name: "Ada Lovelace" }, + }, + }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + expect(decoded.items[0]?.authors).toEqual([ + { login: "ada", name: "Ada Lovelace", avatarUrl: null }, + ]); + expect(decoded.items[1]?.messageHeadline).toBe("second"); + expect(decoded.next).toBeNull(); + }); + + it("skips commits whose hash is empty", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, + { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); + }); +}); + +describe("decodeStatusesJson", () => { + it("reads a build status as a check", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { + key: "custom:check-version-and-pr", + name: "Pipeline - custom: check-version-and-pr", + state: "SUCCESSFUL", + description: "", + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ]), + ), + ); + + expect(decoded).toEqual({ + items: [ + { + name: "Pipeline - custom: check-version-and-pr", + status: "success", + description: null, + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ], + next: null, + }); + }); + + it.each([ + ["SUCCESSFUL", "success"], + ["FAILED", "failure"], + ["INPROGRESS", "pending"], + ["STOPPED", "cancelled"], + ["something new", "neutral"], + ])("reads the %s build state as %s", (state, expected) => { + const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); + + expect(decoded.items[0]?.status).toBe(expected); + }); +}); + +describe("decodeDiffstatJson", () => { + it("adds up the per-file counts", () => { + const decoded = expectSuccess( + decodeDiffstatJson( + page([ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 32, lines_removed: 14 }, + ]), + ), + ); + + expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); + }); +}); + +describe("decodeConflictsJson", () => { + it("calls an empty conflict list mergeable", () => { + expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); + }); + + it("calls any reported conflict conflicting", () => { + expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); + }); +}); + +describe("repository permission decoding", () => { + const permissionPage = (permission: string) => + page([{ type: "repository_permission", permission }]); + + it("counts admin and write as write, and read as not", () => { + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); + }); + + it("grants write where Bitbucket named no permission at all", () => { + // An empty page is Bitbucket declining to say, which is an unknown standing rather than a + // refusal — and an unknown one is granted. + expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts new file mode 100644 index 00000000000..697ab2bbb97 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -0,0 +1,614 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestMergeability, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant + * style as the GitHub and GitLab decoders: a new pull request state or build status must not + * fail a whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are + * not accepted there. Braced, and sent back exactly as it arrived. + */ + uuid: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent on an app account, which is why `display_name` has to stand in for it. */ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), + links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + avatar: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** + * Required, and required to be non-empty: the wire contract will not carry a change request + * without a branch or a link, so a row missing one is skipped rather than breaking the response + * it travels in. + */ +const RawBranchSchema = Schema.Struct({ + branch: Schema.Struct({ name: TrimmedNonEmptyString }), +}); + +const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); + +const RawPullRequestSchema = Schema.Struct({ + id: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source: RawBranchSchema, + destination: RawBranchSchema, + created_on: Schema.String, + updated_on: Schema.String, + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + participants: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), + role: Schema.optional(Schema.NullOr(Schema.String)), + approved: Schema.optional(Schema.Boolean), + state: Schema.optional(Schema.NullOr(Schema.String)), + participated_on: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), +}); + +const RawPageSchema = Schema.Struct({ + values: Schema.Array(Schema.Unknown), + /** A total count, which Bitbucket omits on some endpoints. */ + size: Schema.optional(Schema.NullOr(Schema.Int)), + /** Present only while a further page exists. */ + next: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.Int, + content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + created_on: Schema.String, + deleted: Schema.optional(Schema.Boolean), + /** A comment still being drafted by its author. */ + pending: Schema.optional(Schema.Boolean), + /** Set on a reply, to the comment it answers — which may itself be a reply. */ + parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), + inline: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + /** The line in the file as it was; set instead of `to` on a removed line. */ + from: Schema.optional(Schema.NullOr(Schema.Int)), + /** The line in the file as it is now. */ + to: Schema.optional(Schema.NullOr(Schema.Int)), + outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), + /** Non-null once someone has marked the thread resolved. */ + resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), + links: Schema.optional( + Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), + ), +}); + +const RawCommitSchema = Schema.Struct({ + hash: TrimmedNonEmptyString, + message: Schema.optional(Schema.NullOr(Schema.String)), + date: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional( + Schema.NullOr( + Schema.Struct({ + raw: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + }), + ), + ), +}); + +const RawStatusSchema = Schema.Struct({ + key: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawDiffstatSchema = Schema.Struct({ + lines_added: Schema.optional(Schema.NullOr(Schema.Int)), + lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ +const RawMemberSchema = Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), +}); + +const RawViewerSchema = Schema.Struct({ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket + * states what the credentials may do with it: nothing on the repository, the pull request or the + * workspace carries it. One row, or none where Bitbucket names no permission for this account. + */ +const RawRepositoryPermissionsSchema = Schema.Struct({ + values: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + ), +}); + +export interface BitbucketPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + /** + * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The + * detail read asks the conflicts endpoint, which does answer. + */ + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + readonly updatedAt: string; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; + /** Approvals and change requests, which Bitbucket keeps on its participants. */ + readonly reviews: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from + * every host against each other as plain strings, so they are normalized to the same `Z` form + * the other hosts already use. + */ +function toIsoUtc(value: string): string { + return Option.match(DateTime.make(value), { + onNone: () => value, + onSome: DateTime.formatIso, + }); +} + +/** An app account has no nickname, so the display name is the only handle it has. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); + return login === null + ? null + : { + login, + name: trimmed(raw?.display_name), + avatarUrl: trimmed(raw?.links?.avatar?.href), + }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.state?.trim().toUpperCase()) { + case "MERGED": + return "merged"; + case "DECLINED": + case "SUPERSEDED": + return "closed"; + default: + return "open"; + } +} + +function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toUpperCase()) { + case "SUCCESSFUL": + return "success"; + case "FAILED": + return "failure"; + case "STOPPED": + return "cancelled"; + case "INPROGRESS": + return "pending"; + default: + return "neutral"; + } +} + +/** + * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in + * the conversation. Participants who have only been added carry no verdict and are skipped. + */ +function toReviews( + raw: Schema.Schema.Type, +): ReadonlyArray { + return (raw.participants ?? []).flatMap((participant): ReadonlyArray => { + const author = toActor(participant.user); + const votedAt = trimmed(participant.participated_on); + const reviewState = + trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); + if (author === null || votedAt === null || reviewState === null) return []; + return [ + { + id: `${raw.id}:${author.login}`, + kind: "review", + author, + body: "", + createdAt: toIsoUtc(votedAt), + url: null, + path: null, + reviewState, + }, + ]; + }); +} + +function toPullRequest(raw: Schema.Schema.Type): BitbucketPullRequest { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + author: toActor(raw.author), + headBranch: raw.source.branch.name, + baseBranch: raw.destination.branch.name, + state: toState(raw), + isDraft: raw.draft ?? false, + mergeability: "unknown", + createdAt: toIsoUtc(raw.created_on), + updatedAt: toIsoUtc(raw.updated_on), + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), + reviews: toReviews(raw), + }; +} + +const decodePage = decodeJsonResult(RawPageSchema); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); +const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); +const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeConflicts = decodeJsonResult(RawPageSchema); +const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); + +type DecodeFailure = Cause.Cause; + +export interface BitbucketPage { + readonly items: ReadonlyArray; + /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ + readonly next: string | null; +} + +/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ +export function decodePullRequestPageJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: BitbucketPullRequest[] = []; + for (const entry of decoded.success.values) { + const item = decodePullRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toPullRequest(item.value)); + } + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) + : Result.fail(decoded.failure); +} + +/** + * Whether the configured credentials can write to the repository, which is what merging needs. + * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at + * all for this account — an unknown standing, which is granted rather than guessed away. + */ +export function decodeRepositoryPermissionJson(raw: string): Result.Result { + const decoded = decodeRepositoryPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; + return Result.succeed(permission === null || permission === "admin" || permission === "write"); +} + +/** + * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". + * Nothing on a repository lists the people with access to it — `permissions-config/users` is for + * administrators only — and a pull request can be sent to anyone in the workspace, so this is the + * list Bitbucket's own reviewer field is filled from too. + * + * Nobody is marked requested here: who has been asked lives on the pull request, and only the + * caller holds both. + */ +export function decodeWorkspaceMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success.values) { + const member = decodeMemberEntry(entry); + if (Exit.isFailure(member)) continue; + const uuid = trimmed(member.value.user?.uuid); + const actor = toActor(member.value.user); + if (uuid === null || actor === null) continue; + items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ +export type BitbucketRawComment = Schema.Schema.Type; + +export interface BitbucketComments { + readonly comments: ReadonlyArray; + /** + * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can + * land on different pages, and only the caller holding every page can put them together. + */ + readonly entries: ReadonlyArray; + readonly next: string | null; +} + +/** + * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a + * line opens a thread, and every reply that leads back to it belongs in it. A reply whose + * parent is on a page that was not read has nowhere to go, and is left out rather than shown + * as a thread of its own — it still stands in the flat conversation, which needs no parent. + */ +export function buildReviewThreads( + comments: ReadonlyArray, +): ReadonlyArray { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + const rootOf = (comment: Schema.Schema.Type) => { + // Bounded by the number of comments read, so a parent cycle cannot spin here. + let current = comment; + for (let step = 0; step < byId.size; step += 1) { + const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); + if (parent === undefined) return current; + current = parent; + } + return current; + }; + + const threads = new Map(); + const replies = new Map>>(); + for (const comment of comments) { + const root = rootOf(comment); + const inline = root.inline; + const path = trimmed(inline?.path); + if (path === null) continue; + if (root.id === comment.id) { + // `to` is the line as the file stands now, `from` the line it replaced; a comment that + // carries only `from` was written against the removed side. + const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; + const line = side === "left" ? inline?.from : inline?.to; + threads.set(root.id, { + id: String(root.id), + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolution !== null && root.resolution !== undefined, + isOutdated: inline?.outdated === true, + comments: [], + }); + } + const bucket = replies.get(root.id); + if (bucket === undefined) replies.set(root.id, [comment]); + else bucket.push(comment); + } + + return [...threads.values()].flatMap((thread) => { + const entries = (replies.get(Number(thread.id)) ?? []) + .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) + .map((comment) => ({ + id: String(comment.id), + author: toActor(comment.user), + body: comment.content?.raw ?? "", + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + })); + return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; + }); +} + +/** + * Deleted comments and ones their author has not posted yet carry nothing to show. A comment + * pinned to a file is a line-level review comment, which is what that kind means. + */ +export function decodeCommentsJson(raw: string): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + const kept: Array = []; + for (const entry of decoded.success.values) { + const decodedComment = decodeCommentEntry(entry); + if (Exit.isFailure(decodedComment)) continue; + const comment = decodedComment.value; + if (comment.deleted === true || comment.pending === true) continue; + const body = comment.content?.raw ?? ""; + if (body.trim().length === 0) continue; + kept.push(comment); + const path = trimmed(comment.inline?.path); + comments.push({ + id: String(comment.id), + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.user), + body, + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + path, + reviewState: null, + }); + } + return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success.values) { + const decodedCommit = decodeCommitEntry(entry); + if (Exit.isFailure(decodedCommit)) continue; + const commit = decodedCommit.value; + const committedDate = trimmed(commit.date); + if (committedDate === null) continue; + const linkedAuthor = toActor(commit.author?.user); + const rawAuthor = trimmed(commit.author?.raw); + commits.push({ + oid: commit.hash, + messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", + committedDate: toIsoUtc(committedDate), + authors: + linkedAuthor !== null + ? [linkedAuthor] + : rawAuthor === null + ? [] + : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], + }); + } + // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. + return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); +} + +export function decodeStatusesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const checks: PullRequestCheck[] = []; + for (const entry of decoded.success.values) { + const decodedStatus = decodeStatusEntry(entry); + if (Exit.isFailure(decodedStatus)) continue; + const status = decodedStatus.value; + const name = trimmed(status.name) ?? trimmed(status.key); + if (name === null) continue; + checks.push({ + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }); + } + return Result.succeed({ items: checks, next: trimmed(decoded.success.next) }); +} + +export interface BitbucketDiffStat { + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; +} + +export interface BitbucketDiffStatPage extends BitbucketDiffStat { + readonly next: string | null; +} + +/** One entry per changed file, each carrying that file's line counts. */ +export function decodeDiffstatJson( + raw: string, +): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + let additions = 0; + let deletions = 0; + let changedFiles = 0; + for (const entry of decoded.success.values) { + const decodedStat = decodeDiffstatEntry(entry); + if (Exit.isFailure(decodedStat)) continue; + additions += decodedStat.value.lines_added ?? 0; + deletions += decodedStat.value.lines_removed ?? 0; + changedFiles += 1; + } + return Result.succeed({ + additions, + deletions, + changedFiles, + next: trimmed(decoded.success.next), + }); +} + +/** + * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the + * only statement Bitbucket makes that a pull request merges cleanly. + */ +export function decodeConflictsJson( + raw: string, +): Result.Result { + const decoded = decodeConflicts(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") + : Result.fail(decoded.failure); +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts new file mode 100644 index 00000000000..1827d731669 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -0,0 +1,906 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + decodeViewerPermissionsJson, + reviewThreadConversation, +} from "./gitHubPullRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("pull request list decoding", () => { + it("treats a merge timestamp as merged even when the state still says closed", () => { + const [entry] = expectSuccess( + decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), + ).items; + expect(entry?.state).toBe("merged"); + }); + + it("normalizes mergeability and defaults unknown values", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), + ), + ); + expect(batch.items.map((entry) => entry.mergeability)).toEqual([ + "conflicting", + "unknown", + "unknown", + ]); + }); + + it("keeps user review requests and drops team ones, which are not logins", () => { + const [entry] = expectSuccess( + decodePullRequestListJson( + listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), + ), + ).items; + expect(entry?.reviewRequestLogins).toEqual(["octocat"]); + }); + + it("skips malformed entries but still counts them, so paging does not stop early", () => { + const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; + const batch = expectSuccess(decodePullRequestListJson(raw)); + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("pull request detail decoding", () => { + const detailJson = JSON.stringify({ + number: 7, + title: "Detail", + url: "https://github.com/pingdotgg/t3code/pull/7", + headRefName: "feat/detail", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + body: "Body", + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, + { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, + ], + comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], + reviews: [ + { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, + { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, + ], + commits: [ + { + oid: "abc1234", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + authors: [ + { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, + { name: "Pair Author", email: "pair@example.com" }, + ], + }, + ], + }); + + it("maps check-run status and commit-status state onto one vocabulary", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["build", "pending"], + ["test", "failure"], + ["ci/legacy", "success"], + ]); + }); + + it("merges reviews with comments in time order and keeps a bodyless approval", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + // r2 approved without writing anything, which is still the event worth seeing. + expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); + expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); + }); + + it("keeps every attributed commit author, including an unlinked signature", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.commits[0]?.authors).toEqual([ + { login: "octocat", name: "Octo Cat", avatarUrl: null }, + { login: "Pair Author", name: "Pair Author", avatarUrl: null }, + ]); + }); + + it("drops the bodyless review GitHub opens to hold line comments", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + reviews: [ + // What a reviewer leaving inline comments produces: a container with a state but + // nothing to read. Its comments come from the review threads instead. + { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, + { + id: "r5", + body: "Looks good.", + state: "COMMENTED", + submittedAt: "2026-07-08T00:00:00Z", + }, + ], + }), + ), + ); + + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); + }); + + it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( + "keeps a bodyless %s review, which is the event itself", + (state) => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], + }), + ), + ); + + expect(detail.comments.map((comment) => comment.id)).toContain("r6"); + }, + ); + + it("drops a review that carries neither a body nor a state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], + }), + ), + ); + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + totalCount = nodes.length, + pageInfo: Record = { hasNextPage: false, endCursor: null }, + ): string => + JSON.stringify({ + data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, + }); + + /** The same query carries the review roster, so it is built alongside the threads. */ + const reviewJson = (input: { + readonly requested?: ReadonlyArray; + readonly reviewed?: ReadonlyArray; + }): string => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reviewRequests: { + nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), + }, + latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, + }, + }, + }, + }); + + it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], + // An app that has reviewed is no longer an outstanding request, which is why asking + // only for requests reported nobody on a pull request a bot had reviewed. + reviewed: [{ login: "macroscopeapp", avatarUrl: "https://avatars/in/900172.png" }], + }), + ), + ); + + expect(result.reviewers).toEqual([ + { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, + { login: "macroscopeapp", name: null, avatarUrl: "https://avatars/in/900172.png" }, + ]); + }); + + it("carries per-commit line counts from the pull-request connection", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { commit: { oid: "abc123", additions: 18, deletions: 7 } }, + { commit: { oid: "def456", additions: 3, deletions: 0 } }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...result.commitStats]).toEqual([ + ["abc123", { additions: 18, deletions: 7 }], + ["def456", { additions: 3, deletions: 0 }], + ]); + }); + + it("lists someone who was asked and then answered only once", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + }), + ), + ); + + expect(result.reviewers).toHaveLength(1); + }); + + it("skips a team request, which names nobody to show", () => { + const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); + + expect(result.reviewers).toEqual([]); + }); + + it("keeps the conversation when a request is from a team, which has no login", () => { + // GraphQL answers with an empty object for a union member the query has no fragment for. + // Failing on it would take the whole response down, comments included. + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), + ), + ); + + expect(result.reviewers).toEqual([ + { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, + ]); + }); + + it("carries a resolved thread into the conversation, which was still said", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_a", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + { + id: "PRRT_b", + isResolved: true, + path: "apps/web/src/main.tsx", + comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + expect(comments[0]).toMatchObject({ + id: "t1", + kind: "review-comment", + path: "apps/server/src/ws.ts", + }); + }); + + it("carries every reply, not only the remark each thread opened with", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_c", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [ + { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, + { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, + ], + }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + }); + + it("hands back the cursor the next page of threads carries on from", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson( + [ + { + id: "PRRT_d", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ], + 80, + { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, + ), + ), + ); + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("keeps GitHub's own count of a thread whose comments were not all read", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_e", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { + totalCount: 140, + pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, + nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + ]), + ), + ); + expect(result.threads[0]).toMatchObject({ + commentCount: 140, + nextCommentCursor: "Y3Vyc29yOjI", + }); + }); + + it("ends a thread's walk on the last page, which still names a cursor", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, + nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + }, + }), + ), + ); + expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); + expect(decoded.nextCursor).toBeNull(); + }); +}); + +describe("repository access decoding", () => { + const repositoryJson = (viewerPermission?: string | null) => + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...(viewerPermission === undefined ? {} : { viewerPermission }), + }); + + it("reads the three settings gh reports", () => { + expect( + expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities, + ).toEqual({ merge: true, squash: false, rebase: true }); + }); + + it("fails rather than defaulting open when a setting is missing", () => { + const decoded = decodeRepositoryAccessJson(JSON.stringify({ mergeCommitAllowed: true })); + expect(Result.isSuccess(decoded)).toBe(false); + }); + + it("counts the roles that can push as write, and the ones that cannot as read", () => { + for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + true, + ); + } + for (const permission of ["TRIAGE", "READ", "NONE"]) { + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + false, + ); + } + }); + + it("withholds write where gh names no permission, which is not a standing it gave", () => { + // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes + // the press, where a missing one still leaves the pull request open on its host. + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false); + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false); + }); +}); + +describe("viewer permission decoding", () => { + const viewerJson = (repository: Record) => + JSON.stringify({ data: { repository } }); + + it("reads the repository's role and the pull request's own viewer fields together", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }), + ), + ), + ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + }); + + it("says no to a passer-by on a repository they can only read", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }), + ), + ), + ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false }); + }); + + it("reads silence as permission, but not as authorship", () => { + // A node the viewer cannot see comes back null. Updating is a permission, so an unknown + // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, + // and claiming it for someone who did not is how an author's own rules get handed out. + expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ + canWrite: false, + canUpdate: true, + didAuthor: false, + }); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + pullRequest: Record = {}, + ) => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: nodes.length, nodes }, + author: null, + comments: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { nodes: [] }, + ...pullRequest, + }, + }, + }, + }); + + it("carries what the reader may do with the pull request, off the conversation read", () => { + // The same response the threads arrive in, so knowing this costs no request of its own. + expect( + expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), + ), + ).viewer, + ).toEqual({ canUpdate: false, didAuthor: false }); + expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ + canUpdate: true, + didAuthor: false, + }); + }); + + const comment = (id: string, body: string) => ({ + id, + author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, + body, + createdAt: "2026-07-01T00:00:00Z", + url: `https://github.com/acme/web/pull/1#discussion_r${id}`, + }); + + it("anchors a thread to its line and side, keeping the whole conversation", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_1", + isResolved: false, + isOutdated: false, + path: "src/a.ts", + line: 42, + diffSide: "LEFT", + comments: { totalCount: 2, nodes: [comment("c1", "first"), comment("c2", "second")] }, + }, + ]), + ), + ); + expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ + { + id: "PRRT_1", + path: "src/a.ts", + line: 42, + side: "left", + isResolved: false, + isOutdated: false, + comments: [ + { + id: "c1", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "first", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/acme/web/pull/1#discussion_rc1", + }, + { + id: "c2", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "second", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/acme/web/pull/1#discussion_rc2", + }, + ], + }, + ]); + }); + + it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_2", + isResolved: true, + isOutdated: true, + path: "src/a.ts", + // GitHub reports no current line once the thread has fallen off the diff. + line: null, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, + }, + ]), + ), + ); + expect(reviewThreads.threads[0]?.thread).toMatchObject({ + line: null, + isOutdated: true, + isResolved: true, + }); + }); + + it("keeps a resolved thread in the conversation as well as against its line", () => { + const decoded = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_3", + isResolved: true, + path: "src/a.ts", + line: 7, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c4", "done")] }, + }, + ]), + ), + ); + // A resolved conversation is finished work, not unsaid work: the timeline reads it and the + // diff pins it to its line, the same as any other. + const threads = decoded.threads.map((entry) => entry.thread); + expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); + expect(threads).toHaveLength(1); + }); +}); + +describe("reviewer candidate decoding", () => { + const candidatesJson = (input: { + readonly assignable: ReadonlyArray | null>; + readonly requested?: ReadonlyArray | null>; + readonly author?: string; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.assignable, + }, + pullRequest: { + author: input.author === undefined ? null : { login: input.author }, + reviewRequests: { + nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), + }, + }, + }, + }, + }); + + it("leaves the author out of the people their own pull request can be sent to", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], + author: "bilal", + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "octocat", + kind: "user", + login: "octocat", + name: "The Octocat", + avatarUrl: null, + isRequested: false, + }, + ]); + expect(list.truncated).toBe(false); + }); + + it("marks whoever has already been asked, and leaves the rest to be asked", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }, { login: "hubot" }], + requested: [{ login: "octocat" }], + }), + ), + ); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }); + + it("keeps a requested team apart from the people, so the request can be taken back", () => { + // A team is never among the assignable users, and a request that cannot be seen cannot be + // undone — so the ones GitHub reports are carried, marked as the teams they are. + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }], + requested: [{ slug: "reviewers", name: "Reviewers" }], + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "reviewers", + kind: "team", + login: "reviewers", + name: "Reviewers", + avatarUrl: null, + isRequested: true, + }, + { + id: "octocat", + kind: "user", + login: "octocat", + name: null, + avatarUrl: null, + isRequested: false, + }, + ]); + }); + + it("says so when the repository has more people than the read asked for", () => { + expect( + expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), + ), + ).truncated, + ).toBe(true); + }); +}); + +describe("reviewer request payload", () => { + it("sends people and teams in the two lists GitHub keeps them in", () => { + expect( + JSON.parse( + buildReviewerRequestJson([ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + { id: "hubot", kind: "user" }, + ]), + ), + ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); + }); + + it("sends both lists even where one of them is empty, which is what GitHub reads", () => { + expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({ + reviewers: ["octocat"], + team_reviewers: [], + }); + }); +}); + +describe("review submission payload", () => { + it("sends the verdict, the summary and every line comment in one body", () => { + const payload = JSON.parse( + buildReviewSubmissionJson({ + verdict: "request-changes", + body: "Two things.", + comments: [ + { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, + { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + ], + }), + ) as Record; + expect(payload).toEqual({ + event: "REQUEST_CHANGES", + body: "Two things.", + comments: [ + { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, + { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, + ], + }); + }); + + it("sends an approval with no words and no comments", () => { + expect( + JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })), + ).toEqual({ event: "APPROVE", body: "", comments: [] }); + }); +}); + +describe("decodePullRequestFilesJson", () => { + it("assembles a unified patch the files API does not return", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + expect(result.rawCount).toBe(1); + }); + + it("points an added file at /dev/null on the left and a removed one on the right", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, + { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/new.ts b/src/new.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/new.ts", + "@@ -0,0 +1 @@", + "+hello", + "diff --git a/src/gone.ts b/src/gone.ts", + "deleted file mode 100644", + "--- a/src/gone.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-bye", + "", + ].join("\n"), + ); + }); + + it("names both paths of a rename, counting its hunks against the old one", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + status: "renamed", + previous_filename: "src/old.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + // Binary: it changed, and none of it can be shown. + { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, + { + filename: "src/app.ts", + status: "modified", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + // Dropping it would take the file out of the change altogether, not just its contents. + expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); + expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(result.truncated).toBe(true); + expect(result.rawCount).toBe(2); + }); + + it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + previous_filename: "src/old.ts", + status: "renamed", + additions: 0, + deletions: 0, + }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.truncated).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts new file mode 100644 index 00000000000..6bd126222ba --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -0,0 +1,1487 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidate, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + PullRequestThreadComment, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` + * release that adds a conclusion or a review state must not fail the whole payload. + */ +const RawActorSchema = Schema.Struct({ + /** + * Optional because a review can be requested from a team or a mannequin, which the query has + * no fragment for and GraphQL answers with an empty object. A reviewer with no login names + * nobody to show, and must not fail the response the conversation travels in. + */ + login: Schema.optional(Schema.String), + /** The node id, which is how a listing's authors are resolved to avatars in one request. */ + id: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewRequestSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawListItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + labels: Schema.optional(Schema.Array(RawLabelSchema)), +}); + +/** + * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` + * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional + * because a node that is not a pull request decodes as an empty object, which is skipped. + */ +const RawSearchItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + }), + ), + ), + ), + ), + }), + ), + ), + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), + }), + ), + ), +}); + +const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ + search: Schema.Struct({ + pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), + // Row by row, like the listing's own: a node that is not a pull request — or one field + // GitHub changes — is skipped rather than blanking every repository at once. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +/** One aliased lookup per row, so the response is keyed by the position it was asked in. */ +const RawStatsSchema = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), + }), + ), + ), + ), + ), +}); + +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommitSchema = Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, + authors: Schema.optional( + Schema.Array( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + id: Schema.optional(Schema.NullOr(Schema.String)), + login: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawDetailSchema = Schema.Struct({ + ...RawListItemSchema.fields, + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), + comments: Schema.optional(Schema.Array(RawCommentSchema)), + reviews: Schema.optional(Schema.Array(RawReviewSchema)), + commits: Schema.optional(Schema.Array(RawCommitSchema)), +}); + +/** Where a connection carries on from, which is what every paged read below follows. */ +const RawPageInfoSchema = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * What GitHub says the viewer may do with a pull request. Both are optional so that an install + * that answers without them still delivers the conversation they travel with; an absent field + * reads as granted, which is what an unknown permission is. + */ +const RawViewerFieldsSchema = Schema.Struct({ + viewerCanUpdate: Schema.optional(Schema.Boolean), + viewerDidAuthor: Schema.optional(Schema.Boolean), +}); + +const RawThreadCommentsSchema = Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(RawCommentSchema), +}); + +/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ +const RawReviewThreadsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + reviewThreads: Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + isResolved: Schema.optional(Schema.Boolean), + isOutdated: Schema.optional(Schema.Boolean), + path: Schema.optional(Schema.NullOr(Schema.String)), + /** Null once the thread's line has left the diff, which `isOutdated` reports. */ + line: Schema.optional(Schema.NullOr(Schema.Int)), + diffSide: Schema.optional(Schema.NullOr(Schema.String)), + comments: RawThreadCommentsSchema, + }), + ), + }), + ...RawViewerFieldsSchema.fields, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), + ), + }), + ), + ), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + // Null for a team, which is a request nobody in particular owns. + requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + }), + ), + }), + ), + ), + latestReviews: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), + ), + }), + ), + ), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + oid: Schema.String, + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + }), + }), + ), + }), + ), + ), + }), + }), + }), +}); + +/** Requested together, so a response missing any of them fails rather than defaulting open: + * guessing `true` would offer a merge method the repository forbids. */ +const RawRepositoryAccessSchema = Schema.Struct({ + mergeCommitAllowed: Schema.Boolean, + squashMergeAllowed: Schema.Boolean, + rebaseMergeAllowed: Schema.Boolean, + /** + * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the + * three above: an install that does not report it leaves the viewer's standing unknown, which + * is answered by granting rather than by failing the whole detail read. + */ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestFileSchema = Schema.Struct({ + filename: Schema.String, + status: Schema.optional(Schema.NullOr(Schema.String)), + /** Only on a rename, where it names the file the hunks are counted against. */ + previous_filename: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent for a binary file, and for one whose diff GitHub considers too large. */ + patch: Schema.optional(Schema.NullOr(Schema.String)), + /** Whether anything was withheld is the difference between a binary file and a pure rename. */ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ +export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on User { login avatarUrl } + ... on Bot { login avatarUrl } + } +}`; + +const RawActorAvatarsSchema = Schema.Struct({ + data: Schema.Struct({ + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), +}); + +const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); + +export function decodeActorAvatarsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeActorAvatars(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const avatarsByLogin = new Map(); + for (const node of decoded.success.data.nodes) { + const login = trimmed(node?.login); + const avatarUrl = trimmed(node?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + } + return Result.succeed(avatarsByLogin); +} + +export const PULL_REQUEST_LIST_JSON_FIELDS = + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; + +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup,comments,reviews,commits`; + +/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ +const GRAPHQL_PAGE_SIZE = 100; + +/** + * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: + * `first: 101` is an error, `first: 100` is not). + */ +export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; + +/** + * Every repository of a host in one read, which is what makes a listing one request rather than + * one process per repository. + * + * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a + * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two + * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. + * + * The row count is written into the document rather than sent as a variable because every + * variable here travels as a string — and it is this module's own number, clamped by the caller, + * never a reader's. + * + * `first` on the two inner connections is a bound rather than a page: a pull request with more + * than twenty labels shows twenty, and one that has asked more than twenty people for a review + * is already past what a row can say. + */ +export function pullRequestSearchGraphQlQuery(rows: number): string { + return `query($q: String!) { + search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { + pageInfo { hasNextPage } + nodes { + ... on PullRequest { + number + title + url + author { login avatarUrl ... on User { name } } + headRefName + baseRefName + state + isDraft + mergeable + createdAt + updatedAt + mergedAt + repository { nameWithOwner } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } + labels(first: 20) { nodes { name color } } + } + } + } +}`; +} + +/** + * One page of review threads with their comments, and the people on the review. `$cursor` is + * null for the first page and the last page's `endCursor` after that, so a pull request with + * more threads than one page holds is walked rather than cut off at the first fifty. + * + * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: + * that field holds only requests still outstanding, so anyone who has already reviewed drops off + * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person + * or an app, and both are asked for by name because they are different GraphQL types. + * + * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the + * pull request this query is already standing on, so what the reader may do with it arrives with + * the conversation rather than costing a request of its own. + */ +export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + isOutdated + path + line + diffSide + comments(first: ${GRAPHQL_PAGE_SIZE}) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { id author { login avatarUrl } body createdAt url } + } + } + } + viewerCanUpdate + viewerDidAuthor + author { login avatarUrl } + comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } } + reviewRequests(first: 50) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + latestReviews(first: 50) { + nodes { author { login avatarUrl } } + } + commits(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { commit { oid additions deletions } } + } + } + } +}`; + +/** + * The rest of one thread's conversation. GraphQL pages a connection nested inside another only + * from the inner node itself, so a thread longer than a page is followed on its own — a request + * GitHub makes necessary, and one no ordinary pull request ever provokes. + */ +export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id author { login avatarUrl } body createdAt url } + } + } + } +}`; + +const RawReviewThreadCommentsSchema = Schema.Struct({ + data: Schema.Struct({ + /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ + node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })), + }), +}); + +export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}`; + +export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +/** + * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document + * rather than as `-f name=value` flags, so a reader's own words never reach argv. + */ +const GraphQlRequestSchema = Schema.Struct({ + query: Schema.String, + variables: Schema.Record(Schema.String, Schema.String), +}); + +const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema)); + +export function encodeGraphQlRequestJson(input: { + readonly query: string; + readonly variables: Readonly>; +}): string { + return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); +} + +/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ +const ReviewSubmissionSchema = Schema.Struct({ + event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), + body: Schema.String, + comments: Schema.Array( + Schema.Struct({ + path: Schema.String, + line: Schema.Int, + side: Schema.Literals(["LEFT", "RIGHT"]), + body: Schema.String, + }), + ), +}); + +const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); + +const REVIEW_EVENTS: Record = { + comment: "COMMENT", + approve: "APPROVE", + "request-changes": "REQUEST_CHANGES", +}; + +/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ +export function buildReviewSubmissionJson(input: { + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; +}): string { + return encodeReviewSubmission({ + event: REVIEW_EVENTS[input.verdict], + body: input.body, + comments: input.comments.map((comment) => ({ + path: comment.path, + line: comment.line, + side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + body: comment.body, + })), + }); +} + +/** + * `viewerPermission` rides along with the merge settings rather than being asked for on its own: + * `gh repo view --json` serves both out of the same GraphQL repository object, so the viewer's + * standing on the repository costs no request of its own. + */ +export const REPOSITORY_ACCESS_JSON_FIELDS = + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; + +export interface GitHubPullRequestListItem { + /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ + readonly authorId: string | null; + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + /** At least one outstanding request targets a team rather than an individual login. */ + readonly hasTeamReviewRequest: boolean; + readonly labels: ReadonlyArray; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Null once a connection has nothing further, which is what ends every walk below. GitHub sends + * an `endCursor` on a page that is also the last one, so the flag is what decides, not the + * cursor's presence. + */ +function nextCursorOf( + pageInfo: Schema.Schema.Type | undefined, +): string | null { + return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; +} + +/** + * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. + * + * Updating is a permission, so an install that does not report it grants it and lets the host's + * own refusal explain anything that fails. Authorship is not a permission but a fact about who + * wrote the thing, and it is read to decide what an author may do to their own change — so an + * unknown answer is "not the author", which grants nothing it should not. + */ +function toPullRequestViewerFields( + raw: Schema.Schema.Type | null | undefined, +): { readonly canUpdate: boolean; readonly didAuthor: boolean } { + return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.login); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) }; +} + +function toCommitActor( + raw: NonNullable["authors"]>[number], +): PullRequestActor | null { + // An email-linked GitHub account has a login; an unlinked signature only has a name or email. + // Keep that signature visible instead of silently turning a co-authored commit into one author. + const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); + return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; +} + +function toState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + if (trimmed(raw.mergedAt) !== null) return "merged"; + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + return "open"; +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function toLabels( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label.name); + return name === null ? [] : [{ name, color: trimmed(label.color) }]; + }); +} + +/** + * User review requests only. Team requests are tracked separately because a slug cannot be + * compared with the viewer's login. + */ +function toReviewRequestLogins( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((request) => { + const login = trimmed(request.login); + return login === null ? [] : [login]; + }); +} + +function hasTeamReviewRequest( + raw: ReadonlyArray> | undefined, +): boolean { + return (raw ?? []).some( + (request) => + trimmed(request.login) === null && + (trimmed(request.slug) !== null || trimmed(request.name) !== null), + ); +} + +function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { + // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` + // that only exists once the run has completed. + const status = raw.status?.trim().toUpperCase(); + if (status !== undefined && status !== "COMPLETED" && status !== "") { + return "pending"; + } + switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + // A completed check asking for manual intervention is blocking, not neutral. + case "ACTION_REQUIRED": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + ]; + }); +} + +/** The states that are a verdict in themselves, rather than a wrapper around line comments. */ +function isReviewVerdict(reviewState: string | null): boolean { + switch (reviewState?.toUpperCase()) { + case "APPROVED": + case "CHANGES_REQUESTED": + case "DISMISSED": + return true; + default: + return false; + } +} + +function toComments( + raw: Schema.Schema.Type, +): ReadonlyArray { + const issueComments = (raw.comments ?? []).map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + }), + ); + // A review with no body is kept only when its state is the event itself — an approval, a + // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the + // container for line comments, and those comments are read from the review threads, so + // keeping the container too would show a row with a name and nothing under it. + const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { + const submittedAt = trimmed(review.submittedAt); + const reviewState = trimmed(review.state); + if ( + submittedAt === null || + ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) + ) { + return []; + } + return [ + { + id: review.id, + kind: "review", + author: toActor(review.author), + body: review.body ?? "", + createdAt: submittedAt, + url: trimmed(review.url), + path: null, + reviewState, + }, + ]; + }); + return [...issueComments, ...reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { + return { + authorId: trimmed(raw.author?.id), + number: raw.number, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeable), + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), + hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + return { + ...toListItem(raw), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + comments: toComments(raw), + commits: (raw.commits ?? []).map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate: commit.committedDate, + authors: (commit.authors ?? []).flatMap((author) => { + const actor = toCommitActor(author); + return actor === null ? [] : [actor]; + }), + })), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); +const decodeSearch = decodeJsonResult(RawSearchSchema); +const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); +const decodeStats = decodeJsonResult(RawStatsSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); +const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); +const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); +const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected pull request + * must not blank the whole list. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitHubPullRequestListItem[] = []; + for (const entry of decoded.success) { + const item = decodeListEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + } + } + return Result.succeed({ items, rawCount: decoded.success.length }); +} + +export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { + /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ + readonly repository: string; +} + +export interface GitHubPullRequestSearchBatch { + readonly items: ReadonlyArray; + /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; + /** More rows than this slice asked for, which is truncation for every repository in it. */ + readonly hasNextPage: boolean; +} + +/** + * A search answers with the same pull request the listing does, one connection deeper: reviewers + * and labels arrive as connections, and the row names the repository it came from. Flattened to + * the shape `gh pr list --json` hands over so both reads decode into one type. + * + * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing + * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. + */ +export function decodePullRequestSearchJson( + raw: string, +): Result.Result { + const decoded = decodeSearch(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const nodes = decoded.success.data.search.nodes ?? []; + const items: GitHubPullRequestSearchItem[] = []; + for (const entry of nodes) { + const decodedNode = decodeSearchItem(entry); + if (!Exit.isSuccess(decodedNode)) continue; + const node = decodedNode.value; + const repository = trimmed(node.repository?.nameWithOwner); + if (repository === null) continue; + items.push({ + ...toListItem({ + ...node, + reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { + const login = trimmed(request?.requestedReviewer?.login); + return login === null ? [] : [{ login }]; + }), + labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), + }), + repository, + }); + } + return Result.succeed({ + items, + rawCount: nodes.length, + hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false, + }); +} + +/** What a repository selector may hold before it is written into a GraphQL document unquoted. */ +const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; + +/** + * The line counts for rows a listing already handed over, as one aliased lookup each. + * + * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a + * repository and a number — and never sees a node id. Owner, name and number are written into + * the document, so each is checked against what GitHub can actually name first: null for anything + * else, which the caller reports rather than sends. + * + * Null too for an empty request, since a GraphQL document with no selection is not a document. + */ +export function buildPullRequestStatsGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`, + ); + } + return `query {\n${selections.join("\n")}\n}`; +} + +/** + * The counts by the position they were asked in. A repository or a pull request GitHub answered + * nothing for is simply absent, which leaves the row with whatever it already had. + */ +export function decodePullRequestStatsJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure +> { + const decoded = decodeStats(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const stats = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + const pullRequest = value?.pullRequest; + if (index === undefined || pullRequest == null) continue; + stats.set(Number(index), { + additions: pullRequest.additions ?? 0, + deletions: pullRequest.deletions ?? 0, + }); + } + return Result.succeed(stats); +} + +export function decodePullRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export interface GitHubReviewThreadComments { + readonly comments: ReadonlyArray; + /** Whole conversations, kept anchored so the diff can pin them to their line. */ + readonly reviewThreads: ReadonlyArray; + /** The host's own count of the conversation, which a bounded read can fall short of. */ + readonly commentCount: number; + readonly truncated: boolean; + /** + * Everyone on the review: those still asked and those who have already answered. Whoever has + * reviewed is no longer an outstanding request, so asking only for requests reports nobody on + * a pull request that has in fact been reviewed. + */ + readonly reviewers: ReadonlyArray; + /** + * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of + * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, + * so an app's avatar arrives the same way a person's does. + */ + readonly avatarsByLogin: ReadonlyMap; + /** Per-commit line counts carried by the same bounded pull-request query. */ + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + /** What GitHub says the reader may do with this pull request, read off the same response. */ + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; +} + +/** One thread as this page found it, with what it takes to finish reading it. */ +export interface GitHubReviewThreadEntry { + readonly thread: PullRequestReviewThread; + /** How many comments GitHub says the thread holds, read or not. */ + readonly commentCount: number; + /** Where the rest of this thread's comments carry on from, or null once it is whole. */ + readonly nextCommentCursor: string | null; +} + +export interface GitHubReviewThreadPage { + readonly threads: ReadonlyArray; + /** Where the next page of threads starts, or null once the host has handed them all over. */ + readonly nextCursor: string | null; + readonly reviewers: ReadonlyArray; + readonly avatarsByLogin: ReadonlyMap; + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; +} + +/** + * The threads as one flat conversation, which is what the timeline reads. Every comment of + * every thread, resolved or not: a resolved conversation is still what was said, and a reply is + * as much of it as the remark it answers. + */ +export function reviewThreadConversation( + threads: ReadonlyArray, +): ReadonlyArray { + return threads.flatMap((thread) => + thread.comments.map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + }), + ), + ); +} + +/** One page of review threads. Following the cursors it hands back is the caller's job. */ +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads = decoded.success.data.repository.pullRequest.reviewThreads; + const entries = threads.nodes.flatMap((thread): ReadonlyArray => { + const path = trimmed(thread.path); + const id = trimmed(thread.id); + if (path === null || id === null || thread.comments.nodes.length === 0) return []; + return [ + { + thread: { + id, + path, + // Null once the thread's line has left the diff, which is exactly when GitHub reports + // it outdated. Such a thread is listed rather than pinned to a line it no longer has. + line: + thread.line !== null && thread.line !== undefined && thread.line > 0 + ? thread.line + : null, + side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", + isResolved: thread.isResolved === true, + isOutdated: thread.isOutdated === true, + comments: thread.comments.nodes.map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + })), + }, + commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, + nextCommentCursor: nextCursorOf(thread.comments.pageInfo), + }, + ]; + }); + const pullRequest = decoded.success.data.repository.pullRequest; + const avatarsByLogin = new Map(); + for (const raw of [ + pullRequest.author, + ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), + ]) { + const login = trimmed(raw?.login); + const avatarUrl = trimmed(raw?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + } + const reviewers = new Map(); + for (const raw of [ + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ]) { + const actor = toActor(raw); + // Keyed by login, so someone who was asked and then answered appears once. + if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); + } + const commitStats = new Map(); + for (const node of pullRequest.commits?.nodes ?? []) { + const commit = node.commit; + const oid = trimmed(commit.oid); + if (oid === null || commit.additions === undefined || commit.deletions === undefined) continue; + commitStats.set(oid, { + additions: Math.max(0, commit.additions), + deletions: Math.max(0, commit.deletions), + }); + } + return Result.succeed({ + threads: entries, + nextCursor: nextCursorOf(threads.pageInfo), + reviewers: [...reviewers.values()], + avatarsByLogin, + commitStats, + viewer: toPullRequestViewerFields(pullRequest), + }); +} + +/** The rest of one thread's comments, in the shape the first page already delivered them. */ +export function decodeReviewThreadCommentsJson(raw: string): Result.Result< + { + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewThreadComments(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments = decoded.success.data.node?.comments; + return Result.succeed({ + comments: (comments?.nodes ?? []).map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + })), + nextCursor: nextCursorOf(comments?.pageInfo), + }); +} + +/** What one `gh repo view` answers: what the repository allows, and where the viewer stands. */ +export interface GitHubRepositoryAccess { + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly canWrite: boolean; +} + +/** + * Whether the viewer's role on the repository is one that can push, which is what merging needs. + * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. + * + * An install that reports no permission at all does not count as write. This is the exception to + * "an unknown permission is granted": write is what merging and closing somebody else's change + * need, and offering those to a reader who cannot use them wastes the press and reads as the app + * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, + * because being unable to say something is the worse failure there. + */ +function toCanWrite(viewerPermission: string | null | undefined): boolean { + switch (viewerPermission?.trim().toUpperCase()) { + case "ADMIN": + case "MAINTAIN": + case "WRITE": + return true; + default: + return false; + } +} + +export function decodeRepositoryAccessJson( + raw: string, +): Result.Result { + const decoded = decodeRepositoryAccess(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + mergeCapabilities: { + merge: decoded.success.mergeCommitAllowed, + squash: decoded.success.squashMergeAllowed, + rebase: decoded.success.rebaseMergeAllowed, + }, + canWrite: toCanWrite(decoded.success.viewerPermission), + }) + : Result.fail(decoded.failure); +} + +/** + * Who a review may be asked of, and who it has already been asked of, in one read. + * + * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access + * to the repository — rather than `collaborators`, which the REST API refuses to anyone without + * push access and which would therefore be empty for exactly the reader most likely to be looking. + * + * Teams are asked for only where one has already been requested, so a request to a team can be + * taken back. The teams a repository could newly be sent to live on the owning organization and + * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses + * fails whole, taking the people down with the teams. + */ +export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { login name avatarUrl } + } + pullRequest(number: $number) { + author { login } + reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Team { slug name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + } + } +}`; + +/** A team answers with a slug where a user answers with a login, and nothing else differs. */ +const RawRequestedReviewerSchema = Schema.Struct({ + ...RawActorSchema.fields, + slug: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewerCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + assignableUsers: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), + }), + ), + }), + ), + ), + }), + ), + }), + }), +}); + +const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); + +/** + * The people this pull request may be sent to, with whoever is already on it marked. The author is + * dropped rather than shown as an unusable row: GitHub refuses a review request from the person + * who opened the pull request, so offering them is offering a failure. + * + * Whoever has been asked leads the list even where GitHub does not count them assignable — an + * outside collaborator, an app — because a request that cannot be seen cannot be taken back. + */ +export function decodeReviewerCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeReviewerCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const pullRequest = repository.pullRequest; + const author = trimmed(pullRequest?.author?.login); + const candidates = new Map(); + for (const node of pullRequest?.reviewRequests?.nodes ?? []) { + const raw = node.requestedReviewer; + const slug = trimmed(raw?.slug); + const id = slug ?? trimmed(raw?.login); + if (id === null) continue; + candidates.set(`${slug === null ? "user" : "team"} ${id}`, { + id, + kind: slug === null ? "user" : "team", + login: id, + name: trimmed(raw?.name), + avatarUrl: trimmed(raw?.avatarUrl), + isRequested: true, + }); + } + for (const node of repository.assignableUsers.nodes) { + const login = trimmed(node?.login); + if (login === null || login === author || candidates.has(`user ${login}`)) continue; + candidates.set(`user ${login}`, { + id: login, + kind: "user", + login, + name: trimmed(node?.name), + avatarUrl: trimmed(node?.avatarUrl), + isRequested: false, + }); + } + return Result.succeed({ + candidates: [...candidates.values()], + truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, + }); +} + +/** + * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which + * takes people and teams in two lists of its own. The same body serves both methods, because + * GitHub takes a request back from exactly whoever it was made of. + */ +const ReviewerRequestSchema = Schema.Struct({ + reviewers: Schema.Array(Schema.String), + team_reviewers: Schema.Array(Schema.String), +}); + +const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema)); + +export function buildReviewerRequestJson( + reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, +): string { + return encodeReviewerRequest({ + reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), + team_reviewers: reviewers.flatMap((reviewer) => + reviewer.kind === "team" ? [reviewer.id] : [], + ), + }); +} + +/** + * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the + * repository, the other two about this pull request in particular — which is why an author with + * only read access can still be told apart from a passer-by. + */ +export interface GitHubViewerAccess { + readonly canWrite: boolean; + /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ + readonly canUpdate: boolean; + readonly didAuthor: boolean; +} + +/** + * The viewer's standing, asked on its own. Only the write path needs this: reading a pull request + * already carries the same three fields on calls it was making anyway, and this exists so that a + * merge or a close is decided by what GitHub says now rather than by what the page was told when + * it loaded. + */ +export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + viewerPermission + pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } + } +}`; + +const RawViewerPermissionsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr(RawViewerFieldsSchema), + }), + }), +}); + +const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); + +export function decodeViewerPermissionsJson( + raw: string, +): Result.Result { + const decoded = decodeViewerPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + return Result.succeed({ + canWrite: toCanWrite(repository.viewerPermission), + ...toPullRequestViewerFields(repository.pullRequest), + }); +} + +export interface GitHubPullRequestFilesPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ + readonly truncated: boolean; + /** Files GitHub returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; +} + +/** + * The files API returns hunks per file with no `diff --git` header, so the unified patch every + * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's + * job, which is why the raw file count comes back with the patch. + */ +export function decodePullRequestFilesJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeFileEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.patch ?? ""; + const status = value.status?.trim().toLowerCase(); + if (hunks.length === 0) { + // A file with no hunks is still a file that changed: a pure rename has none to give, and + // a binary one has none that can be shown. Both are listed, and only the second is a hole + // in the patch — leaving them out entirely would drop them from the change altogether. + if ((value.additions ?? 0) + (value.deletions ?? 0) > 0) truncated = true; + } + // A rename counts its hunks against the old path, which is the only place it is named. + const oldPath = + status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename; + const header = [ + `diff --git a/${oldPath} b/${value.filename}`, + // The files API reports no file mode, so the ordinary one stands in: the viewer reads + // these lines as "added" and "removed" rather than for the mode they carry. + ...(status === "added" ? ["new file mode 100644"] : []), + ...(status === "removed" ? ["deleted file mode 100644"] : []), + ...(status === "renamed" ? [`rename from ${oldPath}`, `rename to ${value.filename}`] : []), + `--- ${status === "added" ? "/dev/null" : `a/${oldPath}`}`, + `+++ ${status === "removed" ? "/dev/null" : `b/${value.filename}`}`, + ].join("\n"); + sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join(""), + truncated, + rawCount: decoded.success.length, + }); +} diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts new file mode 100644 index 00000000000..553bd7fb6ef --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -0,0 +1,455 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeViewerJson, +} from "./gitLabMergeRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function detailJson(entry: Record): string { + return JSON.stringify({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodeMergeRequestListJson", () => { + it("reads a merge request as a change request", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([ + { + iid: 42, + author: { username: "bilal", name: "Bilal" }, + state: "opened", + merge_status: "can_be_merged", + draft: false, + reviewers: [{ username: "julius" }], + labels: ["backend", " "], + }, + ]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + author: { login: "bilal", name: "Bilal" }, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewRequestLogins: ["julius"], + labels: [{ name: "backend", color: null }], + }); + }); + + it("reports no line counts, which GitLab does not expose", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); + + expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); + }); + + it("treats a merged timestamp as merged whatever the state says", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), + ), + ); + + expect(batch.items[0]?.state).toBe("merged"); + }); + + it("keeps a locked merge request open", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); + + expect(batch.items[0]?.state).toBe("open"); + }); + + it("reads the legacy draft flag", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); + + expect(batch.items[0]?.isDraft).toBe(true); + }); + + it("calls a conflicted merge request conflicting even while the merge check is pending", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), + ); + + expect(batch.items[0]?.mergeability).toBe("conflicting"); + }); + + it("leaves an unfinished merge check unknown", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), + ); + + expect(batch.items[0]?.mergeability).toBe("unknown"); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawIndexes).toEqual([1]); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("decodeMergeRequestDetailJson", () => { + it("reads the description, file count and pipeline", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + description: "Ships the page.", + changes_count: "3", + reviewers: [{ username: "julius", name: "Julius" }], + head_pipeline: { + status: "success", + web_url: "https://gitlab.com/acme/web/-/pipelines/9", + source: "merge_request_event", + }, + }), + ), + ); + + expect(detail.body).toBe("Ships the page."); + expect(detail.changedFiles).toBe(3); + expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + expect(detail.checks).toEqual([ + { + name: "Pipeline", + status: "success", + description: "merge_request_event", + url: "https://gitlab.com/acme/web/-/pipelines/9", + }, + ]); + }); + + it("reads an uncounted change set as its floor", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), + ); + + expect(detail.changedFiles).toBe(1000); + }); + + it("falls back to no file count when GitLab omits one", () => { + const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); + + expect(detail.changedFiles).toBe(0); + }); + + it("maps a pipeline waiting on a person to neutral, not failure", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), + ); + + expect(detail.checks[0]?.status).toBe("neutral"); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); + }); + + it("returns nothing when the account has no username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); + }); +}); + +describe("decodeNotesJson", () => { + it("keeps comments and drops GitLab's own activity notes", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 1, + body: "assigned to @bilal", + system: true, + created_at: "2026-07-01T00:00:00Z", + }, + { + id: 2, + body: "Looks good.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, + ]), + ), + ); + + expect(notes.comments).toHaveLength(1); + expect(notes.comments[0]).toMatchObject({ + id: "2", + kind: "issue-comment", + body: "Looks good.", + }); + // The raw count keeps the dropped notes visible to the caller, which needs them to page. + expect(notes.rawCount).toBe(3); + }); + + it("reads a line note as a review comment on its file", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 7, + type: "DiffNote", + body: "Rename this.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: "src/app.ts", old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); + + it("falls back to the old path for a note on a deleted line", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 8, + type: "DiffNote", + body: "Gone.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: null, old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]?.path).toBe("src/old.ts"); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + }); + + it("skips commits whose id is empty", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); + }); + + it("falls back to the creation timestamp when there is no commit date", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + created_at: "2026-07-01T00:00:00+08:00", + author_name: "Ada Lovelace", + author_email: "ada@example.com", + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ + oid: "aaa", + committedDate: "2026-07-01T00:00:00+08:00", + authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], + }); + }); + + it("carries commit additions and deletions when GitLab returns stats", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + committed_date: "2026-07-01T00:00:00Z", + stats: { additions: 21, deletions: 8, total: 29 }, + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); + }); +}); + +describe("decodeMergeRequestDiffsJson", () => { + it("assembles a unified patch GitLab does not return", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/app.ts", + new_path: "src/app.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + }); + + it("points a new file at /dev/null on the left and a deleted file on the right", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/new.ts", + new_path: "src/new.ts", + new_file: true, + b_mode: "100755", + diff: "@@ -0,0 +1 @@\n+hello\n", + }, + { + old_path: "src/gone.ts", + new_path: "src/gone.ts", + deleted_file: true, + diff: "@@ -1 +0,0 @@\n-bye\n", + }, + ]), + ), + ); + + expect(result.patch).toContain("new file mode 100755"); + expect(result.patch).toContain("--- /dev/null"); + expect(result.patch).toContain("deleted file mode 100644"); + expect(result.patch).toContain("+++ /dev/null"); + }); + + it("records a rename so the patch names both paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.patch).toContain("rename to src/new.ts"); + }); + + it("reports truncation for a file GitLab refused to inline", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), + ), + ); + + expect(result.truncated).toBe(true); + expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); + }); + + it("reports how many files GitLab returned, so the caller can page", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify( + Array.from({ length: 3 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ), + ), + ); + + expect(result.rawCount).toBe(3); + expect(result.truncated).toBe(false); + expect(result.patch).toContain("src/2.ts"); + }); + + it("fails when GitLab did not return a list", () => { + expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); + }); +}); + +describe("merge request viewer fields", () => { + it("carries GitLab's own answer for whether this viewer can merge", () => { + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) + .viewerCanMerge, + ).toBe(false); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) + .viewerCanMerge, + ).toBe(true); + }); + + it("leaves merging permitted where GitLab answered without the field", () => { + // Only the single-merge-request endpoint carries `user`, and an install that answers without + // it has said nothing about the viewer rather than said no. + expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, + ).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts new file mode 100644 index 00000000000..5c0fd0ac753 --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -0,0 +1,698 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeability, + PullRequestMergeCapabilities, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that + * adds a pipeline status or a merge status must not fail the whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the + * id is carried alongside the handle rather than looked up again when a review is asked for. + */ + id: Schema.optional(Schema.Int), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPipelineSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + web_url: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source_branch: Schema.String, + target_branch: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + merge_status: Schema.optional(Schema.NullOr(Schema.String)), + has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), + created_at: Schema.String, + updated_at: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. + changes_count: Schema.optional(Schema.NullOr(Schema.String)), + head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), + /** + * What the requesting account may do, which only the single-merge-request endpoint carries. + * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts + * for the role, the approval rules and a protected target branch — none of which a project's + * access level on its own would tell apart. + */ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), + ), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ + system: Schema.optional(Schema.Boolean), + type: Schema.optional(Schema.NullOr(Schema.String)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +/** + * A discussion note carrying its place in the diff, which is the shape the whole thread view + * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a + * discussion resolved once every resolvable note in it is. + */ +const RawDiscussionNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + system: Schema.optional(Schema.Boolean), + resolvable: Schema.optional(Schema.Boolean), + resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + position_type: Schema.optional(Schema.NullOr(Schema.String)), + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + new_line: Schema.optional(Schema.NullOr(Schema.Int)), + old_line: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), +}); + +const RawDiscussionSchema = Schema.Struct({ + id: Schema.String, + notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), +}); + +const RawDiffRefsSchema = Schema.Struct({ + diff_refs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + base_sha: Schema.String, + head_sha: Schema.String, + start_sha: Schema.String, + }), + ), + ), +}); + +const RawCommitSchema = Schema.Struct({ + id: TrimmedNonEmptyString, + title: Schema.optional(Schema.NullOr(Schema.String)), + committed_date: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + parent_ids: Schema.optional(Schema.Array(Schema.String)), + author_name: Schema.optional(Schema.NullOr(Schema.String)), + author_email: Schema.optional(Schema.NullOr(Schema.String)), + stats: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + }), + ), + ), +}); + +const RawDiffSchema = Schema.Struct({ + old_path: Schema.String, + new_path: Schema.String, + a_mode: Schema.optional(Schema.NullOr(Schema.String)), + b_mode: Schema.optional(Schema.NullOr(Schema.String)), + new_file: Schema.optional(Schema.Boolean), + renamed_file: Schema.optional(Schema.Boolean), + deleted_file: Schema.optional(Schema.Boolean), + diff: Schema.optional(Schema.NullOr(Schema.String)), + /** GitLab omits the hunks for a file it considers too large to inline. */ + too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** And for one it collapsed, which withholds them the same way. */ + collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const RawViewerSchema = Schema.Struct({ + username: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A GitLab project settles on one merge strategy plus an optional squash. */ +const RawProjectMergeSettingsSchema = Schema.Struct({ + merge_method: Schema.optional(Schema.NullOr(Schema.String)), + squash_option: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export interface GitLabMergeRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** + * GitLab reports neither added nor removed lines on a merge request, so both stay zero and + * the surface omits the stat. The Code tab counts them from the patch it already fetched. + */ + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + /** False only where GitLab said so; an answer without the field leaves merging permitted. */ + readonly viewerCanMerge: boolean; + /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.username); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + if (trimmed(raw.merged_at) !== null) return "merged"; + switch (raw.state?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +function toMergeability( + raw: Schema.Schema.Type, +): PullRequestMergeability { + if (raw.has_conflicts === true) return "conflicting"; + switch (raw.merge_status?.trim().toLowerCase()) { + case "can_be_merged": + return "mergeable"; + case "cannot_be_merged": + return "conflicting"; + default: + // `unchecked` and `checking` mean GitLab has not finished the merge check yet. + return "unknown"; + } +} + +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + // GitLab returns label names only, so there is no colour to carry. + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +/** + * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is + * the floor either way, which reads better than dropping an uncounted change set to nothing. + */ +function toChangedFiles(value: string | null | undefined): number { + const parsed = Number.parseInt(value?.trim() ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toLowerCase()) { + case "success": + return "success"; + case "failed": + return "failure"; + case "canceled": + case "cancelling": + return "cancelled"; + case "skipped": + return "skipped"; + // A pipeline waiting on a person is not progress, and it is not a failure either. + case "manual": + case "scheduled": + return "neutral"; + default: + return "pending"; + } +} + +/** + * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one + * check. The jobs behind it stay one click away through the pipeline URL. + */ +function toChecks( + raw: Schema.Schema.Type, +): ReadonlyArray { + const pipeline = raw.head_pipeline; + if (!pipeline) return []; + return [ + { + name: "Pipeline", + status: toPipelineStatus(pipeline.status), + description: trimmed(pipeline.source), + url: trimmed(pipeline.web_url), + }, + ]; +} + +function toListItem( + raw: Schema.Schema.Type, +): GitLabMergeRequestListItem { + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + headBranch: raw.source_branch, + baseBranch: raw.target_branch, + state: toState(raw), + isDraft: raw.draft ?? raw.work_in_progress ?? false, + mergeability: toMergeability(raw), + additions: 0, + deletions: 0, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null ? [] : [login]; + }), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { + const listItem = toListItem(raw); + return { + ...listItem, + body: raw.description ?? "", + changedFiles: toChangedFiles(raw.changes_count), + mergedAt: trimmed(raw.merged_at), + closedAt: trimmed(raw.closed_at), + // Built from the reviewers themselves rather than from their logins, so the avatars survive. + reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }), + checks: toChecks(raw), + viewerCanMerge: raw.user?.can_merge !== false, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => + reviewer.id === undefined ? [] : [reviewer.id], + ), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); +const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeCommit = decodeJsonResult(RawCommitSchema); +const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); +const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); +const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitLabProjectUsers { + readonly candidates: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in GitLab's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected merge request + * must not blank the whole list. */ +export function decodeMergeRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitLabMergeRequestListItem[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodeMergeRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +export function decodeMergeRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeMergeRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.username)) + : Result.fail(decoded.failure); +} + +/** + * The people with access to the project, which `GET /projects/:id/users` answers with — the same + * list GitLab's own reviewer field is filled from, including the members a group above the project + * lends it. A malformed row is skipped rather than failing the menu it belongs to. + * + * Nobody is marked requested here: who has been asked lives on the merge request, and only the + * caller holds both. + */ +export function decodeProjectUsersJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const candidates: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success) { + const user = decodeUserEntry(entry); + if (Exit.isFailure(user) || user.value.id === undefined) continue; + const actor = toActor(user.value); + if (actor === null) continue; + candidates.push({ + ...actor, + id: String(user.value.id), + kind: "user", + isRequested: false, + }); + } + return Result.succeed({ candidates, rawCount: decoded.success.length }); +} + +/** + * GitLab settles the strategy per project rather than offering all three per merge request: + * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a + * separate switch. An unrecognized setting offers nothing rather than offering a strategy the + * project forbids. + */ +export function decodeProjectMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeProjectMergeSettings(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); + const squashOption = decoded.success.squash_option?.trim().toLowerCase(); + return Result.succeed({ + merge: mergeMethod === "merge", + // Both semi-linear and fast-forward histories are reached by rebasing onto the target. + rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", + // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, + // rather than offering a squash the project may forbid. + squash: + squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", + }); +} + +/** + * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the + * root of a line-level discussion, which is what the review-comment kind means. + * + * The raw note count comes back alongside, because dropping notes hides whether the page was + * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. + */ +/** The three revisions a positioned comment is written against. */ +export interface GitLabDiffRefs { + readonly baseSha: string; + readonly headSha: string; + readonly startSha: string; +} + +export interface GitLabDiscussions { + readonly threads: ReadonlyArray; + /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ + readonly rawCount: number; +} + +/** + * Positioned discussions only. GitLab returns the merge request's whole conversation here, + * including the plain notes the timeline already shows, and only a positioned one belongs + * against a line of the diff. + */ +export function decodeDiscussionsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads: PullRequestReviewThread[] = []; + for (const entry of decoded.success) { + const discussion = decodeDiscussionEntry(entry); + if (!Exit.isSuccess(discussion)) continue; + const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); + const root = notes[0]; + const position = root?.position; + if (root === undefined || !position || position.position_type !== "text") continue; + // A comment on an added or context line carries `new_line`; one on a removed line carries + // only `old_line`, and belongs against the file as it was. + const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; + const path = trimmed(side === "left" ? position.old_path : position.new_path); + const line = side === "left" ? position.old_line : position.new_line; + if (path === null) continue; + threads.push({ + id: discussion.value.id, + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolved === true, + // GitLab reports no equivalent of "written against a line that has since moved", so a + // thread the diff cannot place is worked out from the diff itself rather than claimed + // here. + isOutdated: false, + comments: notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })), + }); + } + return Result.succeed({ threads, rawCount: decoded.success.length }); +} + +export function decodeDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeDiffRefs(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const refs = decoded.success.diff_refs; + return Result.succeed( + refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, + ); +} + +export function decodeNotesJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly rawCount: number }, + DecodeFailure +> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + if (value.system === true) continue; + const body = value.body ?? ""; + if (body.trim().length === 0) continue; + const isDiffNote = value.type?.trim() === "DiffNote"; + comments.push({ + id: String(value.id), + kind: isDiffNote ? "review-comment" : "issue-comment", + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), + reviewState: null, + }); + } + return Result.succeed({ comments, rawCount: decoded.success.length }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success) { + const commit = decodeCommitEntry(entry); + if (Exit.isFailure(commit)) continue; + const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); + if (committedDate === null) continue; + commits.push({ + oid: commit.value.id, + messageHeadline: commit.value.title ?? "", + committedDate, + ...(commit.value.stats === null || commit.value.stats === undefined + ? {} + : { + additions: Math.max(0, commit.value.stats.additions ?? 0), + deletions: Math.max(0, commit.value.stats.deletions ?? 0), + }), + authors: (() => { + const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); + return login === null + ? [] + : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; + })(), + }); + } + // GitLab lists a merge request's commits newest first; the timeline reads oldest first. + return Result.succeed(commits.toReversed()); +} + +/** The exact comparison GitLab uses for a commit-scoped diff. */ +export function decodeCommitDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeCommit(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const baseSha = trimmed(decoded.success.parent_ids?.[0]); + const headSha = trimmed(decoded.success.id); + return Result.succeed( + baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, + ); +} + +function diffHeaderPaths(raw: Schema.Schema.Type): { + readonly from: string; + readonly to: string; +} { + return { + from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, + to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, + }; +} + +export interface GitLabMergeRequestPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitLab as too large to inline. */ + readonly truncated: boolean; + /** Files GitLab returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; +} + +/** + * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff + * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, + * which is why the raw file count comes back with the patch. + */ +export function decodeMergeRequestDiffsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeDiffEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.diff ?? ""; + if (hunks.length === 0) { + // A file GitLab declined to inline still belongs in the file list, header only. + truncated = truncated || value.too_large === true || value.collapsed === true; + } + const { from, to } = diffHeaderPaths(value); + const header = [ + `diff --git a/${value.old_path} b/${value.new_path}`, + ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), + ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), + ...(value.renamed_file === true + ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] + : []), + `--- ${from}`, + `+++ ${to}`, + ].join("\n"); + sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join("\n"), + truncated, + rawCount: decoded.success.length, + }); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb64c6a4802..3f63eb4dbef 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5233,6 +5233,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, branch: "feature/demo", worktreePath: null, + isOnPullRequestHead: true, }), }, gitVcsDriver: { diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..4f3433693e5 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -756,3 +756,113 @@ it.effect("checks out fork pull requests through an ensured fork remote", () => }); }).pipe(Effect.provide(layer)); }); + +it.effect("refuses a url that points away from the configured Bitbucket", () => { + // A whole url reaches `request` from inside a response — a pagination cursor, say — so + // following one off-host would hand the account's credentials to whoever wrote it. + const { layer, execute } = makeLayer({ response: () => new Response("{}", { status: 200 }) }); + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ method: "GET", url: "https://attacker.example/2.0/repositories" }), + ); + + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + // Nothing was sent at all, so no header travelled anywhere. + assert.strictEqual(execute.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps only the host of a url it refuses, never its query", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ + method: "GET", + // A signed link, whose query is the credential. + url: "https://attacker.example/asset?signature=secret-token", + }), + ); + + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + assert.strictEqual( + error._tag === "BitbucketUntrustedUrlError" ? error.host : "", + "https://attacker.example", + ); + assert.notInclude(error.message, "secret-token"); + }).pipe(Effect.provide(makeLayer({ response: () => new Response("{}", { status: 200 }) }).layer)), +); + +it.effect("does not follow a redirect off the configured Bitbucket", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ method: "GET", url: "/repositories/acme/web/pullrequests/1/diff" }), + ); + + // The client would carry every header to the new host, so the hop is checked here instead. + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + }).pipe( + Effect.provide( + makeLayer({ + response: () => + new Response(null, { + status: 302, + headers: { location: "https://attacker.example/stolen" }, + }), + }).layer, + ), + ), +); + +it.effect("follows a redirect that stays on the configured Bitbucket", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const result = yield* bitbucket.request({ + method: "GET", + url: "/repositories/acme/web/pullrequests/1/diff", + }); + + // Bitbucket serves a diff as a redirect to a commit range, so the hop has to be followed. + assert.strictEqual(result.body, "diff --git a/a.ts b/a.ts"); + assert.isFalse(result.truncated); + }).pipe( + Effect.provide( + makeLayer({ + response: (request) => + request.url.endsWith("/pullrequests/1/diff") + ? new Response(null, { + status: 302, + // The same host the harness configures, which is not bitbucket.org: a + // self-hosted base url has to be trusted on its own terms. + headers: { location: "https://api.test.local/2.0/repositories/acme/web/diff/abc" }, + }) + : new Response("diff --git a/a.ts b/a.ts", { status: 200 }), + }).layer, + ), + ), +); + +it.effect("cuts a response short rather than reading an unbounded diff into memory", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const result = yield* bitbucket.request({ + method: "GET", + url: "/repositories/acme/web/pullrequests/1/diff", + maxBytes: 8, + }); + + assert.strictEqual(result.body, "12345678"); + assert.isTrue(result.truncated); + // Bounded as the body arrives, so an oversized diff is never held whole. + }).pipe( + Effect.provide( + makeLayer({ response: () => new Response("1234567890", { status: 200 }) }).layer, + ), + ), +); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a4..aad28ee8c1a 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -22,11 +22,16 @@ import { normalizeBitbucketPullRequestRecord, type NormalizedBitbucketPullRequestRecord, } from "./bitbucketPullRequests.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; +/** A response body past this is cut short, so one huge diff cannot exhaust the server. */ +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ +const MAX_REDIRECTS = 3; const BitbucketApiEnvConfig = Config.all({ baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( @@ -47,6 +52,9 @@ const BitbucketApiOperation = Schema.Literals([ "createPullRequest", "probeAuth", "checkoutPullRequest", + // The raw escape hatch. Callers name their own operation in their own error, the way the + // pull request wrappers do on top of `gh` and `glab`. + "request", ]); type BitbucketApiOperation = typeof BitbucketApiOperation.Type; @@ -56,8 +64,12 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketUntrustedUrlError", + { + /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ + host: Schema.String, + }, +) { + get detail(): string { + return `The response pointed at ${this.host}, outside the configured Bitbucket.`; + } + + override get message(): string { + return `Bitbucket API failed in request: ${this.detail}`; } } export const BitbucketApiError = Schema.Union([ + BitbucketUntrustedUrlError, BitbucketRepositoryLocatorError, BitbucketRequestError, BitbucketResponseError, @@ -246,6 +316,24 @@ export class BitbucketApi extends Context.Service< BitbucketApi, { readonly probeAuth: Effect.Effect; + + /** + * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints + * with JSON and a few — a pull request diff, for one — with plain text, so the body is + * handed back undecoded for the caller to read as it sees fit. + */ + readonly request: (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + /** + * A path below the API base, or a whole URL as a paged response reports its next page. + * A whole URL is refused unless it belongs to the configured Bitbucket. + */ + readonly url: string; + /** A JSON document, for the endpoints that take one. */ + readonly body?: string; + /** Response bytes to keep; past this the body comes back cut short and marked. */ + readonly maxBytes?: number; + }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; readonly listPullRequests: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; @@ -473,11 +561,25 @@ function authFromConfig( }; } +/** Null for anything that is not a url at all, which is never the configured Bitbucket. */ +function originOf(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + function responseError( operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { - return response.text.pipe( + // Bounded like any other body: an error response is no smaller than a successful one, and + // only its length is reported anyway. + return collectUint8StreamText({ + stream: response.stream, + maxBytes: DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( Effect.mapError( (cause) => new BitbucketResponseBodyReadError({ @@ -486,12 +588,12 @@ function responseError( cause, }), ), - Effect.flatMap((body) => + Effect.flatMap((collected) => Effect.fail( new BitbucketResponseError({ operation, status: response.status, - responseBodyLength: body.length, + responseBodyLength: collected.text.length, }), ), ), @@ -689,7 +791,107 @@ export const make = Effect.gen(function* () { }); }); + // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range + // URL, and the client does not follow redirects unless asked. The hop stays on the same host, + // so the credentials travel with it. + /** + * The one host these credentials may be sent to. A url that came back inside a response — a + * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked + * against this before the account's token travels with it. + */ + const apiOrigin = originOf(config.baseUrl); + + const trustedUrl = (value: string): string | null => { + if (!/^https?:\/\//u.test(value)) return apiUrl(value); + const origin = originOf(value); + return origin !== null && origin === apiOrigin ? value : null; + }; + + /** + * Redirects are followed here rather than by the client, which forwards every header to + * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as + * redirects, so they have to be followed — but only back to the same Bitbucket. + */ + const send = (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + readonly url: string; + readonly body?: string; + readonly redirects: number; + }): Effect.Effect => { + const url = trustedUrl(input.url); + if (url === null) { + return Effect.fail( + new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), + ); + } + const base = + input.method === "GET" + ? HttpClientRequest.get(url) + : input.method === "POST" + ? HttpClientRequest.post(url) + : input.method === "DELETE" + ? HttpClientRequest.make("DELETE")(url) + : HttpClientRequest.put(url); + // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. + const withBody = + input.body === undefined + ? base + : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + return httpClient.execute(withAuth(withBody)).pipe( + Effect.mapError( + (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), + ), + Effect.flatMap((response) => { + const location = response.headers.location; + if ( + response.status >= 300 && + response.status < 400 && + location !== undefined && + input.redirects < MAX_REDIRECTS + ) { + return send({ + ...input, + url: new URL(location, url).toString(), + redirects: input.redirects + 1, + }); + } + return Effect.succeed(response); + }), + ); + }; + + const request: BitbucketApi["Service"]["request"] = (input) => + send({ ...input, redirects: 0 }).pipe( + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + // Read through the body stream rather than `text`, so an oversized diff is stopped + // as it arrives instead of being materialized whole and then cut. The same collector + // the process runner bounds command output with. + "2xx": (success) => + collectUint8StreamText({ + stream: success.stream, + maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation: "request", + status: success.status, + cause, + }), + ), + Effect.map((collected) => ({ + body: collected.text, + truncated: collected.truncated, + })), + ), + orElse: (failed) => responseError("request", failed), + })(response), + ), + ); + return BitbucketApi.of({ + request, probeAuth: executeJson( "probeAuth", HttpClientRequest.get(apiUrl("/user")), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..a705b0fb0b3 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -203,6 +203,9 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -314,6 +317,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0ef..05475400d42 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -249,6 +249,9 @@ export class GitLabCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listMergeRequests: (input: { @@ -401,6 +404,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe(Effect.mapError(mapError)); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index c059f6f0f9e..8c3c5c4de56 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -72,7 +72,12 @@ function encodeAzureDevOpsPathSegment(segment: string): string { return encodeURIComponent(segment); } -function azureDevOpsOrganizationBaseFromRestApiUrl( +/** + * The organization root a REST url belongs to, which is where a browser url and any further + * REST call have to be hung. Exported because the pull requests page derives its own urls from + * whatever Azure returned rather than from the local remote, whose shape varies. + */ +export function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { const rawUrl = trimOptionalString(value); @@ -104,29 +109,53 @@ function azureDevOpsOrganizationBaseFromRestApiUrl( } } -function normalizeAzureDevOpsPullRequestUrl( - raw: Schema.Schema.Type, -): string { - const webLink = trimOptionalString(raw._links?.web?.href); +/** + * Where a pull request lives in a browser. Azure answers with a web link when asked for one and + * otherwise leaves it to be assembled, so all three routes are tried in the order they can be + * trusted. Takes plain fields so both the source control provider and the pull requests page + * can share it. + */ +export function azureDevOpsPullRequestWebUrl(input: { + readonly pullRequestId: number; + readonly webLink?: string | null | undefined; + readonly repositoryWebUrl?: string | null | undefined; + readonly restApiUrl?: string | null | undefined; + readonly projectName?: string | null | undefined; + readonly repositoryName?: string | null | undefined; +}): string { + const webLink = trimOptionalString(input.webLink); if (webLink) { return webLink; } - const repositoryWebUrl = trimOptionalString(raw.repository?.webUrl); + const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); if (repositoryWebUrl) { - return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${raw.pullRequestId}`; + return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; } - const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); - const projectName = trimOptionalString(raw.repository?.project?.name); - const repositoryName = trimOptionalString(raw.repository?.name); + const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); + const projectName = trimOptionalString(input.projectName); + const repositoryName = trimOptionalString(input.repositoryName); if (organizationBase && projectName && repositoryName) { const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); - return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${raw.pullRequestId}`; + return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; } - return trimOptionalString(raw.url) ?? ""; + return trimOptionalString(input.restApiUrl) ?? ""; +} + +function normalizeAzureDevOpsPullRequestUrl( + raw: Schema.Schema.Type, +): string { + return azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }); } function normalizeAzureDevOpsPullRequestRecord( diff --git a/apps/server/src/stream/collectUint8StreamText.test.ts b/apps/server/src/stream/collectUint8StreamText.test.ts index d6715294cce..4a41cf11ec6 100644 --- a/apps/server/src/stream/collectUint8StreamText.test.ts +++ b/apps/server/src/stream/collectUint8StreamText.test.ts @@ -17,6 +17,7 @@ describe("collectUint8StreamText", () => { text: "hello world", bytes: 11, truncated: false, + invalidUtf8: false, }); }), ); @@ -33,7 +34,24 @@ describe("collectUint8StreamText", () => { text: "abcde[truncated]", bytes: 5, truncated: true, + invalidUtf8: false, }); }), ); + + it.effect("reports invalid UTF-8 separately from a literal replacement character", () => + Effect.gen(function* () { + const invalid = yield* collectUint8StreamText({ + stream: Stream.make(new Uint8Array([0x66, 0x80, 0x6f])), + }); + const literal = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("before\uFFFDafter")), + }); + + assert.strictEqual(invalid.invalidUtf8, true); + assert.strictEqual(invalid.text, "f\uFFFDo"); + assert.strictEqual(literal.invalidUtf8, false); + assert.strictEqual(literal.text, "before\uFFFDafter"); + }), + ); }); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts index 7ac5530474e..71114e1de1b 100644 --- a/apps/server/src/stream/collectUint8StreamText.ts +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -1,12 +1,21 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; +import * as NodeBuffer from "node:buffer"; export interface CollectedUint8StreamText { readonly text: string; readonly truncated: boolean; readonly bytes: number; + readonly invalidUtf8: boolean; } +export const decodeUtf8 = ( + bytes: Uint8Array, +): Pick => ({ + text: Buffer.from(bytes).toString("utf8"), + invalidUtf8: !NodeBuffer.isUtf8(bytes), +}); + interface CollectState { chunks: Uint8Array[]; readonly bytes: number; @@ -59,11 +68,15 @@ export const collectUint8StreamText = (input: { }, ), Effect.map((state): CollectedUint8StreamText => { - const text = Buffer.concat(state.chunks, state.bytes).toString("utf8"); + const decoded = decodeUtf8(Buffer.concat(state.chunks, state.bytes)); return { - text: state.truncated && truncatedMarker.length > 0 ? `${text}${truncatedMarker}` : text, + text: + state.truncated && truncatedMarker.length > 0 + ? `${decoded.text}${truncatedMarker}` + : decoded.text, bytes: state.bytes, truncated: state.truncated, + invalidUtf8: decoded.invalidUtf8, }; }), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f256a7dd4e1..8f7f7bd1954 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -144,6 +144,36 @@ export interface GitFetchPullRequestBranchInput { branch: string; } +export interface GitFetchPullRequestHeadCommitInput { + cwd: string; + prNumber: number; +} + +export interface GitResolveCommitInput { + cwd: string; + revision: string; +} + +export interface GitResolveCommitResult { + commitSha: string; +} + +export interface GitRefreshCheckedOutBranchInput { + cwd: string; + targetCommit: string; + /** + * Commit the checkout is allowed to be hard-reset away from: the upstream commit read before + * the fetch. HEAD sitting there means the checkout holds no work of its own. + */ + resetWhenHeadCommit?: string | null | undefined; +} + +export interface GitRefreshCheckedOutBranchResult { + headCommit: string; + moved: boolean; + onTarget: boolean; +} + export interface GitEnsureRemoteInput { cwd: string; preferredName: string; @@ -245,6 +275,17 @@ export class GitVcsDriver extends Context.Service< readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; + /** Fetches `refs/pull//head` without writing a branch, for heads that exist nowhere else. */ + readonly fetchPullRequestHeadCommit: ( + input: GitFetchPullRequestHeadCommitInput, + ) => Effect.Effect; + readonly resolveCommit: ( + input: GitResolveCommitInput, + ) => Effect.Effect; + /** Moves the branch checked out in `cwd` onto `targetCommit`, from inside that worktree. */ + readonly refreshCheckedOutBranch: ( + input: GitRefreshCheckedOutBranchInput, + ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d39817c0ee1..5b9359adaa8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2794,6 +2794,95 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( + "resolveCommit", + )(function* (input) { + const commitSha = yield* runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [ + "rev-parse", + "--verify", + `${input.revision}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = + Effect.fn("fetchPullRequestHeadCommit")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a + // branch, which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchPullRequestHeadCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`], + { + fallbackErrorDetail: "git fetch pull request head failed", + }, + ); + + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); + + const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = + Effect.fn("refreshCheckedOutBranch")(function* (input) { + const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" }); + if (headCommit === input.targetCommit) { + return { headCommit, moved: false, onTarget: true }; + } + + const worktreeChanges = yield* runGitStdout( + "GitVcsDriver.refreshCheckedOutBranch.status", + input.cwd, + ["status", "--porcelain"], + ); + if (worktreeChanges.trim().length > 0) { + return { headCommit, moved: false, onTarget: false }; + } + + const isAncestor = yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.isAncestor", + input.cwd, + ["merge-base", "--is-ancestor", headCommit, input.targetCommit], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can + // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD + // never left the commit the upstream held before the fetch. + if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { + return { headCommit, moved: false, onTarget: false }; + } + + if (!isAncestor) { + // The commit being reset away is about to be reachable from nothing. It is only ever a + // commit the remote already held, but "the remote held it" stops being a way back once + // the head it belonged to has been rewritten, so a ref keeps it findable. + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", + input.cwd, + ["update-ref", "refs/t3code/pre-refresh", headCommit], + { fallbackErrorDetail: "git failed to record the previous checkout commit" }, + ); + } + + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.move", + input.cwd, + // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another + // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` + // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the + // other branch — so a race loses nothing; the refresh fails and is reported instead. + isAncestor + ? ["merge", "--ff-only", input.targetCommit] + : ["reset", "--merge", input.targetCommit], + { + timeoutMs: 30_000, + fallbackErrorDetail: "git failed to move the checkout onto the pull request head", + }, + ); + + return { headCommit: input.targetCommit, moved: true, onTarget: true }; + }); + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { yield* executeGit( @@ -3071,6 +3160,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), + fetchPullRequestHeadCommit, + resolveCommit, + refreshCheckedOutBranch: (input) => + withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c..d202fa48ca4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -192,6 +192,8 @@ describe("VcsProcess.run", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 52db6f9b1fb..299990e56ea 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -37,6 +37,9 @@ export interface VcsProcessOutput { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + /** Present on real process output; optional so narrow test doubles remain lightweight. */ + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; } export class VcsProcess extends Context.Service< @@ -163,6 +166,8 @@ export const make = Effect.gen(function* () { stderr: result.stderr, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, + stdoutInvalidUtf8: result.stdoutInvalidUtf8 ?? false, + stderrInvalidUtf8: result.stderrInvalidUtf8 ?? false, } satisfies VcsProcessOutput; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5cbe64cd413..52e0837e86c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -107,6 +107,8 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -408,6 +410,7 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const pullRequests = yield* PullRequestService.PullRequestService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1634,6 +1637,68 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.pullRequestsList]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsListStats]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDetail]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDiff]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDiff, pullRequests.diff(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDiffFileContents]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsDiffFileContents, + pullRequests.diffFileContents(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsRunAction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsSubmitReview]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsReplyToThread]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReplyToThread, + pullRequests.replyToThread(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetThreadResolution]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetThreadResolution, + pullRequests.setThreadResolution(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsInvalidate]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsReviewerCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReviewerCandidates, + pullRequests.reviewerCandidates(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsRequestReviewers]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsRequestReviewers, + pullRequests.requestReviewers(input), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -2222,6 +2287,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), + Layer.provide( + PullRequestService.layer.pipe( + // One registry entry per supported host; the service only knows the registry. + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(VcsProcess.layer), + ), + ), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bbd27f65ab0..251e59a40a4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -613,9 +613,9 @@ export function BranchToolbarBranchSelector({ // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` + ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` : ""; - const openPrLink = useOpenPrLink(); + const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b5d33facc96..8dc545d31eb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -4,8 +4,13 @@ import { ChevronRightIcon, CopyIcon, GlobeIcon, + InfoIcon, + LightbulbIcon, Maximize2Icon, + MessageSquareWarningIcon, Minimize2Icon, + OctagonAlertIcon, + TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; @@ -38,6 +43,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -84,6 +90,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { @@ -145,6 +152,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, @@ -154,6 +162,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, @@ -161,6 +170,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, @@ -172,6 +182,43 @@ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; +/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, +}; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -905,6 +952,25 @@ function plainHastText(node: unknown): string | null { return parts.every((part) => part !== null) ? parts.join("") : null; } +/** + * Whether the link carries any words of its own. An anchor that is only an image — a badge, a + * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it + * is a stray logo rather than a hint. + */ +function hastHasText(node: unknown): boolean { + if (!node || typeof node !== "object") return false; + if ( + "type" in node && + node.type === "text" && + "value" in node && + typeof node.value === "string" && + node.value.trim().length > 0 + ) { + return true; + } + return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); +} + const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { @@ -1324,6 +1390,7 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openChangeRequestLink = useOpenChangeRequestLink(threadRef); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1416,6 +1483,26 @@ function ChatMarkdown({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, + blockquote({ node: _node, children, ...props }) { + const alert = + GITHUB_ALERT_PRESENTATIONS[ + String((props as Record)["data-alert"] ?? "") + ]; + if (!alert) { + return
{children}
; + } + // Not a
: the stylesheet mutes those, and an alert's body is ordinary + // text under a colored title — which is how the host renders it. + return ( +
+

+ + {alert.label} +

+ {children} +
+ ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = @@ -1473,7 +1560,13 @@ function ChatMarkdown({ onClick?.(event); if (isSameDocumentLink && href) { handleMarkdownFragmentClick(event, href); + return; } + // A link to a change request in a workspace project opens beside the + // conversation instead of in a browser: it is the thing being talked about, and + // the panel it opens offers the browser as one of its actions. Anything else is + // an ordinary link and keeps the `_blank` the shell already handles. + if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { if (!canOpenInPreview || !href || !faviconHost) return; @@ -1502,7 +1595,7 @@ function ChatMarkdown({ }); }} > - {faviconHost ? ( + {faviconHost && hastHasText(node) ? ( {children} diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 60df1cd966f..04561b507c3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -43,7 +43,7 @@ export function hasEnvironmentReconnectWarningGraceElapsed( export function startNewThreadForProject( projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, ): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 07f5d3b5fcc..bdbbbcc74e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,6 +123,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { + pullRequestSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, @@ -143,7 +144,8 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { RightPanelTabs } from "./RightPanelTabs"; +import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; +import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { deriveAgentPanelModel, @@ -1561,6 +1563,17 @@ function ChatViewContent(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< + Record + >({}); + const handlePullRequestTabStatusChange = useCallback((status: PullRequestTabStatus) => { + const id = pullRequestSurfaceId(status); + setPullRequestTabStatuses((current) => + current[id]?.state === status.state && current[id]?.isDraft === status.isDraft + ? current + : { ...current, [id]: status }, + ); + }, []); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -3226,6 +3239,20 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeThreadRef], ); + // The thread's own change request, placed against the project it belongs to. Without a + // project there is nothing to resolve it against, so the caller falls back to the browser. + const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const openThreadPullRequest = useCallback( + (number: number) => { + if (!activeThreadRef || !activeProject || threadRepository === null) return; + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: activeProject.id, + repository: threadRepository, + number, + }); + }, + [activeProject, activeThreadRef, threadRepository], + ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4030,6 +4057,13 @@ function ChatViewContent(props: ChatViewProps) { threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, }); + // The right panel offers the thread's own change request, so it can only offer it once the + // branch has one; until then the picker says so rather than opening an empty panel. + const addPullRequestSurface = useCallback(() => { + if (activeThreadPr === null) return; + openThreadPullRequest(activeThreadPr.number); + }, [activeThreadPr, openThreadPullRequest]); + const pullRequestSurfaceAvailable = activeThreadPr !== null && threadRepository !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); @@ -5981,6 +6015,28 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "pull-request" ? ( + // No onClose: the surface tab's own X owns closing here, and a second X in the header + // would be the same action twice. The thread context also drops the checkout button, so it + // is only right for the thread's own pull request, whose branch is already under the + // reader's feet. A link the agent wrote can open any other one here, and that one has to be + // checkable out like it is anywhere else. + ) : activeRightPanelSurface?.kind === "agents" ? ( {!rightPanelOpen ? panelLayoutControls : null} {rightPanelContent} @@ -6450,10 +6512,15 @@ function ChatViewContent(props: ChatViewProps) { onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} + onAddPullRequest={addPullRequestSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} + terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + pullRequestAvailable={pullRequestSurfaceAvailable} + agentsAvailable + pullRequestStatuses={pullRequestTabStatuses} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a62f5edd4df..a91e0c31ecf 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -36,7 +36,6 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, - DIFF_SURFACE_THEME_UNSAFE_CSS, } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -75,6 +74,7 @@ import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -86,215 +86,6 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} -:is( - [data-line], - [data-line-annotation], - [data-merge-conflict], - [data-merge-conflict-actions], - [data-no-newline] -)[data-selected-line] { - --diffs-line-bg: light-dark( - color-mix( - in lab, - var(--code-background) 88%, - color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) - ), - color-mix( - in lab, - var(--code-background) 80%, - color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) - ) - ) !important; -} - -:is([data-gutter-buffer], [data-column-number])[data-selected-line] { - --diffs-line-bg: light-dark( - color-mix( - in lab, - var(--code-background) 91%, - color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) - ), - color-mix( - in lab, - var(--code-background) 85%, - color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) - ) - ) !important; -} - -[data-indicators="bars"] - :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] { - position: relative; -} - -[data-indicators="bars"] - :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before { - position: absolute !important; - inset-block: 0 !important; - inset-inline-start: 0 !important; - display: block !important; - width: 4px !important; - min-width: 4px !important; - max-width: 4px !important; - height: auto !important; - padding: 0 !important; - content: "" !important; - background-color: var(--diffs-modified-base) !important; - background-image: none !important; -} - -[data-file-info] { - background-color: var(--code-background) !important; - border-block-color: transparent !important; - color: var(--code-foreground) !important; -} - -[data-diffs-header] { - position: sticky !important; - top: 0; - z-index: 4; - background-color: var(--code-background) !important; - border-bottom-color: transparent !important; - align-items: center !important; - font-family: var(--font-sans) !important; - font-size: 12px !important; - line-height: 1 !important; - min-height: 32px !important; - padding-block: 6px !important; - padding-inline: 8px 12px !important; -} - -[data-diffs-header]:hover { - background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) { - height: 24px !important; - margin-block: 0 !important; - background-color: var(--code-background) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-wrapper] { - padding-inline: 8px 12px !important; - background-color: transparent !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-content] { - gap: 8px; - padding-inline: 0 !important; - background-color: transparent !important; - color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; - font-family: var(--font-sans) !important; - font-size: 11px !important; - text-decoration: none !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines] { - display: flex !important; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: 8px; - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::after { - width: auto; - height: 1px; - flex: 1 1 auto; - content: ""; - background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-wrapper] { - grid-template-columns: 0 minmax(0, 1fr) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-content] { - grid-column: 2 !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-expand-button] { - display: none !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ) - [data-separator-content] { - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-separator-content] { - color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); -} - -[data-diffs-header] [data-header-content] { - align-items: center !important; - line-height: 1 !important; -} - -[data-diffs-header] [data-metadata] { - align-items: center !important; - line-height: 1 !important; - font-variant-numeric: tabular-nums; -} - -[data-diffs-header] [data-additions-count], -[data-diffs-header] [data-deletions-count] { - font-family: var(--font-mono) !important; - font-size: 11px !important; - font-variant-numeric: tabular-nums; - line-height: 1 !important; -} - -[data-diffs-header] [data-change-icon], -[data-diffs-header] [data-rename-icon] { - display: block; - flex-shrink: 0; -} - -[data-title] { - cursor: pointer; - transition: - color 120ms ease, - text-decoration-color 120ms ease; - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 2px; - font-family: var(--font-sans) !important; -} - -[data-title]:hover { - color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; - text-decoration-color: currentColor; -} -`; - interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -523,45 +314,14 @@ export default function DiffPanel({ return undefined; } - const source = selectedGitSource; - return async (fileDiff) => { - const newPath = resolveFileDiffPath(fileDiff); - const oldPath = fileDiff.prevName - ? resolveFileDiffPath({ ...fileDiff, name: fileDiff.prevName }) - : newPath; - const result = await getDiffFileContents({ - environmentId: activeThread.environmentId, - input: { - cwd: preview.cwd, - sourceKind: source.kind, - changeType: fileDiff.type, - baseRef: source.baseRef, - headRef: source.headRef, - oldPath, - newPath, - }, - }); - if (result._tag !== "Success") { - throw squashAtomCommandFailure(result); - } - - const newFile = { - name: newPath, - contents: result.value.newContents, - cacheKey: `${source.diffHash}:new:${newPath}`, - }; - if (fileDiff.type === "rename-pure") { - return { oldFile: null, newFile }; - } - return { - oldFile: { - name: oldPath, - contents: result.value.oldContents, - cacheKey: `${source.diffHash}:old:${oldPath}`, - }, - newFile, - }; - }; + return createGitDiffFileContentsLoader(getDiffFileContents, { + environmentId: activeThread.environmentId, + cwd: preview.cwd, + sourceKind: selectedGitSource.kind, + baseRef: selectedGitSource.baseRef, + headRef: selectedGitSource.headRef, + cacheKey: selectedGitSource.diffHash, + }); }, [ activeThread, branchDiffPreview.data, @@ -1114,7 +874,7 @@ export default function DiffPanel({ key={collapseScopeKey ?? reviewSectionId} viewerRef={codeViewRef} codeViewKey={codeViewMountKey} - className="diff-render-surface h-full min-h-0 overflow-auto" + className="h-full min-h-0 overflow-auto" files={codeViewFiles} sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} @@ -1158,16 +918,8 @@ export default function DiffPanel({ overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, stickyHeaders: true, ...(loadDiffFiles ? { loadDiffFiles } : {}), - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 0, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, }} /> diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 20e5c349790..b0df578dde9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -95,6 +95,11 @@ interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; + /** + * Opens the thread's own change request beside it. Absent when the thread has no project to + * place it against, in which case it still opens in the browser. + */ + onOpenPullRequest?: ((number: number) => void) | undefined; } interface PendingDefaultBranchAction { @@ -971,6 +976,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, + onOpenPullRequest, }: GitActionsControlProps) { const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -1213,6 +1219,13 @@ export default function GitActionsControl({ }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { + const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; + // Beside the thread where it was made, the way the browser opens beside it. Checked before + // the shell, which opening in the app does not need. + if (openPr && onOpenPullRequest) { + onOpenPullRequest(openPr.number); + return; + } const api = readLocalApi(); if (!api) { toastManager.add({ @@ -1222,7 +1235,7 @@ export default function GitActionsControl({ }); return; } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; + const prUrl = openPr?.url ?? null; if (!prUrl) { toastManager.add({ type: "error", @@ -1242,7 +1255,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 54fbc12df94..68a16fea5db 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -305,6 +305,7 @@ interface SidebarThreadRowProps { projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; + openPullRequestsInRightPanel: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; @@ -335,13 +336,18 @@ interface SidebarThreadRowProps { ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; + openPrLink: ( + event: React.MouseEvent, + prUrl: string, + threadRef?: ScopedThreadRef, + ) => boolean; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, + openPullRequestsInRightPanel, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, @@ -563,9 +569,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const handlePrClick = useCallback( (event: React.MouseEvent) => { if (!prStatus) return; - openPrLink(event, prStatus.url); + const openedInRightPanel = openPrLink( + event, + prStatus.url, + openPullRequestsInRightPanel ? threadRef : undefined, + ); + if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { + navigateToThread(threadRef); + } }, - [openPrLink, prStatus], + [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { @@ -885,6 +898,7 @@ interface SidebarProjectThreadListProps { isThreadListExpanded: boolean; projectCwd: string; activeRouteThreadKey: string | null; + openPullRequestsInRightPanel: boolean; threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; @@ -916,7 +930,11 @@ interface SidebarProjectThreadListProps { ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: (event: React.MouseEvent, prUrl: string) => void; + openPrLink: ( + event: React.MouseEvent, + prUrl: string, + threadRef?: ScopedThreadRef, + ) => boolean; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -936,6 +954,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( isThreadListExpanded, projectCwd, activeRouteThreadKey, + openPullRequestsInRightPanel, threadJumpLabelByKey, appSettingsConfirmThreadArchive, renamingThreadKey, @@ -988,6 +1007,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( projectCwd={projectCwd} orderedProjectThreadKeys={orderedProjectThreadKeys} isActive={activeRouteThreadKey === threadKey} + openPullRequestsInRightPanel={openPullRequestsInRightPanel} jumpLabel={threadJumpLabelByKey.get(threadKey) ?? null} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} @@ -1053,6 +1073,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + openPullRequestsInRightPanel: boolean; newThreadShortcutLabel: string | null; handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; @@ -1073,6 +1094,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + openPullRequestsInRightPanel, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -2333,6 +2355,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec isThreadListExpanded={isThreadListExpanded} projectCwd={project.workspaceRoot} activeRouteThreadKey={activeRouteThreadKey} + openPullRequestsInRightPanel={openPullRequestsInRightPanel} threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} @@ -2749,6 +2772,7 @@ interface SidebarProjectsContentProps { expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; routeThreadKey: string | null; + openPullRequestsInRightPanel: boolean; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; @@ -2789,6 +2813,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, + openPullRequestsInRightPanel, newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, @@ -2929,6 +2954,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + openPullRequestsInRightPanel={openPullRequestsInRightPanel} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -2961,6 +2987,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + openPullRequestsInRightPanel={openPullRequestsInRightPanel} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -3613,6 +3640,7 @@ export default function LegacySidebar() { expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} + openPullRequestsInRightPanel={routeThreadRef !== null} newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5fa0d6c1c36..7eb07582413 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ -import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; +import type { ContextMenuItem, PreviewSessionSnapshot, PullRequestState } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + Bot, + FileDiff, + Files, + GitPullRequest, + Globe2, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -28,6 +37,10 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; + /** Forwarded to PreviewPanelShell so this surface persists its own width. */ + widthStorageKey?: string; + /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ + defaultWidth?: number; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; @@ -44,19 +57,35 @@ interface RightPanelTabsProps { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddPullRequest: () => void; onAddAgents: () => void; browserAvailable: boolean; + terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + pullRequestAvailable: boolean; + agentsAvailable: boolean; + pullRequestStatuses?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; children: ReactNode; } +export interface PullRequestTabStatus { + projectId: string; + repository: string; + number: number; + state: PullRequestState; + isDraft: boolean; +} + const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", + terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", + pullRequest: "This thread's branch has no pull request yet.", + agents: "Agents are only available from a thread.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; @@ -94,10 +123,14 @@ function RightPanelEmptyState(props: { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddPullRequest: () => void; onAddAgents: () => void; browserAvailable: boolean; + terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + pullRequestAvailable: boolean; + agentsAvailable: boolean; liveAgentCount: number; }) { const actions = [ @@ -114,8 +147,8 @@ function RightPanelEmptyState(props: { label: "Terminal", description: "Start a shell in this workspace.", icon: TerminalSquare, - available: true, - disabledReason: null, + available: props.terminalAvailable, + disabledReason: SURFACE_DISABLED_REASONS.terminal, onClick: props.onAddTerminal, badgeCount: 0, }, @@ -137,12 +170,21 @@ function RightPanelEmptyState(props: { onClick: props.onAddDiff, badgeCount: 0, }, + { + label: "Pull request", + description: "Open the pull request for this thread's branch.", + icon: GitPullRequest, + available: props.pullRequestAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequest, + onClick: props.onAddPullRequest, + badgeCount: 0, + }, { label: "Agents", description: "Watch subagents and workflows run.", icon: Bot, - available: true, - disabledReason: null, + available: props.agentsAvailable, + disabledReason: SURFACE_DISABLED_REASONS.agents, onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, @@ -231,6 +273,8 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); + case "pull-request": + return `#${surface.number}`; case "agents": return "Agents"; case "preview": { @@ -266,10 +310,12 @@ function SurfaceIcon({ surface, sessions, theme, + pullRequestStatuses, }: { surface: RightPanelSurface; sessions: Readonly>; theme: "light" | "dark"; + pullRequestStatuses: Readonly> | undefined; }) { switch (surface.kind) { case "preview": { @@ -292,6 +338,20 @@ function SurfaceIcon({ ); case "terminal": return ; + case "pull-request": { + const status = pullRequestStatuses?.[surface.id] ?? null; + const toneClassName = + status?.state === "merged" + ? "text-violet-600 dark:text-violet-300/90" + : status?.state === "closed" + ? "text-red-600 dark:text-red-300/90" + : status?.isDraft + ? "text-zinc-500 dark:text-zinc-400/80" + : status?.state === "open" + ? "text-emerald-600 dark:text-emerald-300/90" + : "text-muted-foreground"; + return ; + } case "agents": return ; } @@ -382,12 +442,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{pending ? ( Browser - + Terminal @@ -494,7 +561,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Diff - + + + Pull request + + Agents @@ -512,10 +591,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} + onAddPullRequest={props.onAddPullRequest} onAddAgents={props.onAddAgents} browserAvailable={props.browserAvailable} + terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} + pullRequestAvailable={props.pullRequestAvailable} + agentsAvailable={props.agentsAvailable} liveAgentCount={props.liveAgentCount} /> ) : ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aee4a04c0a1..11a986b0f2c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -670,6 +670,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the user visits the thread. wokeAt: string | null; isActive: boolean; + openPullRequestsInRightPanel: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; environmentLabel: string | null; @@ -711,6 +712,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsettle, onUnsnooze, onUnpin, + openPullRequestsInRightPanel, renamingTitle, thread, variant, @@ -998,9 +1000,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { - if (pr?.url) openPrLink(event, pr.url); + if (!pr?.url) return; + const openedInRightPanel = openPrLink( + event, + pr.url, + openPullRequestsInRightPanel ? threadRef : undefined, + ); + if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { + onThreadActivate(threadRef); + } }, - [openPrLink, pr], + [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], ); // All sidebar rows share one surface model. Live threads used to look @@ -3464,6 +3474,7 @@ export default function Sidebar() { // rows resolve to null on their own. wokeAt={threadWokeAt(thread, { now: snoozeNow })} isActive={routeThreadKey === threadKey} + openPullRequestsInRightPanel={routeThreadRef !== null} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 39fe6daceb2..19606443684 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -57,6 +57,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -110,6 +111,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + onOpenPullRequest, onNewThreadInProject, onRunProjectScript, onAddProjectScript, @@ -327,6 +329,7 @@ export const ChatHeader = memo(function ChatHeader({ )} diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index c61826f677d..e0bfecab081 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -5,6 +5,7 @@ import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface PanelLayoutControlsProps { + showTerminalControl?: boolean; terminalAvailable: boolean; terminalOpen: boolean; terminalShortcutLabel: string | null; @@ -18,6 +19,7 @@ interface PanelLayoutControlsProps { } export const PanelLayoutControls = memo(function PanelLayoutControls({ + showTerminalControl = true, terminalAvailable, terminalOpen, terminalShortcutLabel, @@ -33,28 +35,30 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="flex h-full shrink-0 items-center gap-1 [-webkit-app-region:no-drag]" data-panel-layout-controls > - - - - - } - /> - - {terminalAvailable - ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}` - : "Terminal drawer is unavailable"} - - + {showTerminalControl ? ( + + + + + } + /> + + {terminalAvailable + ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}` + : "Terminal drawer is unavailable"} + + + ) : null} ({ }), })); -vi.mock("../files/LocalCommentAnnotation", () => ({ - LocalCommentAnnotation: () => null, +vi.mock("./DiffCommentAnnotation", () => ({ + DiffCommentAnnotation: () => null, })); vi.mock("../files/fileCommentAnnotations", () => ({ diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 0decaa3acf5..f0d989a7d5b 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -6,7 +6,7 @@ import type { FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { CodeView, type CodeViewHandle, type CodeViewProps } from "@pierre/diffs/react"; +import type { CodeViewHandle } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; @@ -18,8 +18,9 @@ import { type ReviewCommentContext, } from "~/reviewCommentContext"; -import { LocalCommentAnnotation } from "../files/LocalCommentAnnotation"; import { nextFileCommentId } from "../files/fileCommentAnnotations"; +import { DiffCommentAnnotation } from "./DiffCommentAnnotation"; +import { StyledDiffCodeView, type StyledDiffCodeViewOptions } from "./StyledDiffCodeView"; interface DiffCommentAnnotationEntry { id: string; @@ -81,7 +82,7 @@ interface AnnotatableCodeViewProps { sectionId: string; sectionTitle: string; composerDraftTarget: ScopedThreadRef | DraftId; - options: NonNullable["options"]>; + options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; renderHeaderPrefix: ( @@ -237,9 +238,9 @@ export function AnnotatableCodeView({ const hasOpenComment = draft !== null; return ( - + key={codeViewKey} - {...(viewerRef ? { ref: viewerRef } : {})} + {...(viewerRef ? { viewerRef } : {})} {...(className ? { className } : {})} items={items} selectedLines={selectedLines} @@ -262,7 +263,7 @@ export function AnnotatableCodeView({ className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"} > {annotation.metadata.entries.map((entry) => ( - { - it("renders the draft composer directly in the selected diff", () => { +describe("DiffCommentAnnotation", () => { + it("renders the shared draft composer directly in the selected diff", () => { const markup = renderToStaticMarkup( - , + , ); expect(markup).toContain("font-sans"); @@ -31,9 +31,35 @@ describe("LocalCommentAnnotation", () => { expect(markup).toContain("cursor-text"); }); + it("lets a pull-request diff configure actions without replacing the composer", () => { + const markup = renderToStaticMarkup( + , + allowEmpty: true, + onAction: vi.fn(), + }} + />, + ); + + expect(markup).toContain("Add a comment…"); + expect(markup).toContain(">Ask"); + expect(markup).toContain(">Add to review"); + expect(markup.match(/]*disabled[^>]*>Add to review<\/button>/)).not.toBeNull(); + const askButton = markup.match(/]*>.*?Ask<\/button>/)?.[0]; + expect(askButton).toBeDefined(); + expect(askButton).not.toContain(' disabled=""'); + }); + it("renders a saved comment without a nested card or redundant range label", () => { const markup = renderToStaticMarkup( - { it("renders draft text owned by the annotation wrapper", () => { const markup = renderToStaticMarkup( - void; +} + +interface DiffCommentAnnotationProps { kind: "draft" | "comment"; rangeLabel: string; text: string; onTextChange?: (text: string) => void; onCancel: () => void; onComment: (text: string) => void; - onDelete: () => void; + onDelete?: () => void; + placeholder?: string; + submitLabel?: string; + pending?: boolean; + secondaryAction?: DiffCommentSecondaryAction; } -export function LocalCommentAnnotation({ +/** The shared inline comment treatment for file previews, thread diffs, and pull-request diffs. */ +export function DiffCommentAnnotation({ kind, rangeLabel, text, @@ -22,36 +36,43 @@ export function LocalCommentAnnotation({ onCancel, onComment, onDelete, -}: LocalCommentAnnotationProps) { + placeholder = "Add a comment…", + submitLabel = "Comment", + pending = false, + secondaryAction, +}: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; + const trimmedText = displayedText.trim(); if (kind === "comment") { return (
event.stopPropagation()} >
); } return (
event.stopPropagation()} @@ -62,7 +83,7 @@ export function LocalCommentAnnotation({ className="relative inline-flex w-full rounded-md border border-border/50 bg-background/20 font-sans text-foreground transition-colors focus-within:border-border/70 [&_[data-slot=textarea]]:min-h-12 [&_[data-slot=textarea]]:cursor-text [&_[data-slot=textarea]]:px-2.5 [&_[data-slot=textarea]]:py-1.5 [&_[data-slot=textarea]]:font-sans [&_[data-slot=textarea]]:text-xs [&_[data-slot=textarea]]:leading-5 max-sm:[&_[data-slot=textarea]]:min-h-12" size="sm" value={displayedText} - placeholder="Add a comment…" + placeholder={placeholder} aria-label={`Comment on lines ${rangeLabel}`} onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)} onFocus={(event) => { @@ -74,9 +95,9 @@ export function LocalCommentAnnotation({ event.preventDefault(); onCancel(); } - if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && displayedText.trim()) { + if (isCommentSubmitShortcut(event, trimmedText, pending)) { event.preventDefault(); - onComment(displayedText.trim()); + onComment(trimmedText); } }} /> @@ -90,12 +111,19 @@ export function LocalCommentAnnotation({ > Cancel - + ) : null} +
diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx new file mode 100644 index 00000000000..1baf3fcba98 --- /dev/null +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + codeViewClassName: null as string | null, + codeViewOptions: null as Record | null, +})); + +vi.mock("@pierre/diffs/react", () => ({ + CodeView: (props: { className: string; options: Record }) => { + testState.codeViewClassName = props.className; + testState.codeViewOptions = props.options; + return null; + }, +})); + +import { StyledDiffCodeView } from "./StyledDiffCodeView"; + +describe("StyledDiffCodeView", () => { + beforeEach(() => { + testState.codeViewClassName = null; + testState.codeViewOptions = null; + }); + + it("always pairs the shared diff styling with its virtualized geometry", () => { + const loadDiffFiles = vi.fn(async () => ({ + oldFile: { name: "before.ts", contents: "before\n" }, + newFile: { name: "after.ts", contents: "after\n" }, + })); + renderToStaticMarkup( + , + ); + + expect(testState.codeViewClassName).toBe("diff-render-surface min-h-0"); + expect(testState.codeViewOptions).toMatchObject({ + theme: "pierre-dark", + stickyHeaders: true, + loadDiffFiles, + itemMetrics: { + diffHeaderHeight: 32, + hunkSeparatorHeight: 24, + paddingTop: 0, + paddingBottom: 0, + }, + layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, + }); + expect(testState.codeViewOptions?.unsafeCSS).toEqual( + expect.stringContaining("[data-unmodified-lines]::before"), + ); + expect(testState.codeViewOptions?.unsafeCSS).toEqual( + expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), + ); + }); +}); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx new file mode 100644 index 00000000000..ad795d3a4ee --- /dev/null +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -0,0 +1,265 @@ +/* oxlint-disable eslint/no-restricted-imports -- This is the single styled adapter around Pierre's raw viewer. */ +import { + CodeView, + type CodeViewHandle, + type CodeViewProps, + type ControlledCodeViewProps, + type UncontrolledCodeViewProps, +} from "@pierre/diffs/react"; +/* oxlint-enable eslint/no-restricted-imports */ +import type { Ref } from "react"; + +import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; + +const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} +:is( + [data-line], + [data-line-annotation], + [data-merge-conflict], + [data-merge-conflict-actions], + [data-no-newline] +)[data-selected-line] { + --diffs-line-bg: light-dark( + color-mix( + in lab, + var(--code-background) 88%, + color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) + ), + color-mix( + in lab, + var(--code-background) 80%, + color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) + ) + ) !important; +} + +:is([data-gutter-buffer], [data-column-number])[data-selected-line] { + --diffs-line-bg: light-dark( + color-mix( + in lab, + var(--code-background) 91%, + color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) + ), + color-mix( + in lab, + var(--code-background) 85%, + color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) + ) + ) !important; +} + +[data-indicators="bars"] + :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] { + position: relative; +} + +[data-indicators="bars"] + :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before { + position: absolute !important; + inset-block: 0 !important; + inset-inline-start: 0 !important; + display: block !important; + width: 4px !important; + min-width: 4px !important; + max-width: 4px !important; + height: auto !important; + padding: 0 !important; + content: "" !important; + background-color: var(--diffs-modified-base) !important; + background-image: none !important; +} + +[data-file-info] { + background-color: var(--code-background) !important; + border-block-color: transparent !important; + color: var(--code-foreground) !important; +} + +[data-diffs-header] { + position: sticky !important; + top: 0; + z-index: 4; + background-color: var(--code-background) !important; + border-bottom-color: transparent !important; + align-items: center !important; + font-family: var(--font-sans) !important; + font-size: 12px !important; + line-height: 1 !important; + min-height: 32px !important; + padding-block: 6px !important; + padding-inline: 8px 12px !important; +} + +[data-diffs-header]:hover { + background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) { + height: 24px !important; + margin-block: 0 !important; + background-color: var(--code-background) !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-separator-wrapper] { + padding-inline: 8px 12px !important; + background-color: transparent !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-separator-content] { + gap: 8px; + padding-inline: 0 !important; + background-color: transparent !important; + color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; + font-family: var(--font-sans) !important; + font-size: 11px !important; + text-decoration: none !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-unmodified-lines] { + display: flex !important; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 8px; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] + [data-unmodified-lines] { + cursor: pointer; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-unmodified-lines]::before, +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-unmodified-lines]::after { + width: auto; + height: 1px; + flex: 1 1 auto; + content: ""; + background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] + [data-separator-wrapper] { + grid-template-columns: 0 minmax(0, 1fr) !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] + [data-separator-content] { + grid-column: 2 !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]) + [data-expand-button] { + display: none !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( + [data-expand-button] + ) + [data-separator-content] { + cursor: pointer; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( + [data-expand-button] + ):hover + [data-separator-content] { + color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; +} + +:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( + [data-expand-button] + ):hover + [data-unmodified-lines]::before, +:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( + [data-expand-button] + ):hover + [data-unmodified-lines]::after { + background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); +} + +[data-diffs-header] [data-header-content] { + align-items: center !important; + line-height: 1 !important; +} + +[data-diffs-header] [data-metadata] { + align-items: center !important; + line-height: 1 !important; + font-variant-numeric: tabular-nums; +} + +[data-diffs-header] [data-additions-count], +[data-diffs-header] [data-deletions-count] { + font-family: var(--font-mono) !important; + font-size: 11px !important; + font-variant-numeric: tabular-nums; + line-height: 1 !important; +} + +[data-diffs-header] [data-change-icon], +[data-diffs-header] [data-rename-icon] { + display: block; + flex-shrink: 0; +} + +[data-title] { + cursor: pointer; + transition: + color 120ms ease, + text-decoration-color 120ms ease; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + font-family: var(--font-sans) !important; +} + +[data-title]:hover { + color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; + text-decoration-color: currentColor; +} +`; + +export type StyledDiffCodeViewOptions = Omit< + NonNullable["options"]>, + "unsafeCSS" | "itemMetrics" | "layout" +>; + +type StyledDiffCodeViewProps = ( + | Omit, "options"> + | Omit, "options"> +) & { + readonly options?: StyledDiffCodeViewOptions; + readonly viewerRef?: Ref>; +}; + +/** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */ +export function StyledDiffCodeView({ + options, + viewerRef, + className, + ...props +}: StyledDiffCodeViewProps) { + return ( + + {...props} + {...(viewerRef ? { ref: viewerRef } : {})} + className={className ? `diff-render-surface ${className}` : "diff-render-surface"} + options={{ + ...options, + unsafeCSS: DIFF_VIEW_UNSAFE_CSS, + itemMetrics: { + diffHeaderHeight: 32, + hunkSeparatorHeight: 24, + paddingTop: 0, + paddingBottom: 0, + }, + layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, + }} + /> + ); +} diff --git a/apps/web/src/components/diffs/commentSubmitShortcut.test.ts b/apps/web/src/components/diffs/commentSubmitShortcut.test.ts new file mode 100644 index 00000000000..434228db57e --- /dev/null +++ b/apps/web/src/components/diffs/commentSubmitShortcut.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; + +describe("isCommentSubmitShortcut", () => { + it("accepts Command or Ctrl+Enter only while an eligible comment is idle", () => { + expect( + isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, "Looks good", false), + ).toBe(true); + expect( + isCommentSubmitShortcut({ key: "Enter", metaKey: false, ctrlKey: true }, "Looks good", false), + ).toBe(true); + expect( + isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, "Looks good", true), + ).toBe(false); + }); + + it("rejects empty comments and unrelated key presses", () => { + expect( + isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, " ", false), + ).toBe(false); + expect( + isCommentSubmitShortcut({ key: "K", metaKey: true, ctrlKey: false }, "Looks good", false), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/diffs/commentSubmitShortcut.ts b/apps/web/src/components/diffs/commentSubmitShortcut.ts new file mode 100644 index 00000000000..ee5626aeb97 --- /dev/null +++ b/apps/web/src/components/diffs/commentSubmitShortcut.ts @@ -0,0 +1,16 @@ +interface CommentSubmitShortcutEvent { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; +} + +/** Shared guard for inline comment composers that submit on Command/Ctrl+Enter. */ +export function isCommentSubmitShortcut( + event: CommentSubmitShortcutEvent, + value: string, + pending: boolean, +): boolean { + return ( + !pending && (event.metaKey || event.ctrlKey) && event.key === "Enter" && value.trim().length > 0 + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 090acdb9c02..f528fe89456 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -52,7 +52,7 @@ import { } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; -import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; +import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; @@ -678,7 +678,7 @@ function EditableFileSurface({ renderAnnotation={(annotation) => (
{annotation.metadata.entries.map((entry) => ( - ; + readonly pending: ReadonlyArray; + readonly draft: boolean; +} + +type ReviewAnnotation = DiffLineAnnotation; + +/** Commits per press of "Show more" in the scope menu. */ +const COMMIT_PAGE_SIZE = 10; + +/** One answer from the host: a whole number of files, and where the next one carries on. */ +interface DiffSlice { + /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ + readonly cursor: string | null; + readonly patch: string; + readonly truncated: boolean; + readonly nextCursor: string | null; +} + +/** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */ +const NO_SLICES: ReadonlyArray = []; + +/** A group while it is still gathering what belongs on its line. */ +interface MutableAnnotationGroup { + readonly side: PullRequestDiffSide; + readonly line: number; + readonly threads: PullRequestReviewThread[]; + readonly pending: PendingReviewComment[]; + draft: boolean; +} + +interface DraftAnchor { + readonly fileKey: string; + readonly path: string; + /** What the file was called before the change, for the hosts that resolve a position by both. */ + readonly oldPath: string | null; + readonly line: number; + readonly side: PullRequestDiffSide; + /** The whole selection, which the comment collapses to one line but a question keeps. */ + readonly range: SelectedLineRange; +} + +/** A range of the diff, and whatever the reader wants to know about it. */ +export interface PullRequestAskSelectionInput { + /** The marked lines, already in the shape the composer draws and the agent reads. */ + readonly comment: ReviewCommentContext; + /** Empty where the reader pressed Ask without typing: the lines are the question. */ + readonly question: string; +} + +/** The contract's sides named the way the diff viewer names them, and back again. */ +function toViewerSide(side: PullRequestDiffSide) { + return side === "left" ? ("deletions" as const) : ("additions" as const); +} + +function fromViewerSide(side: string | undefined): PullRequestDiffSide { + return side === "deletions" ? "left" : "right"; +} + +/** + * Whether the viewer draws this line at all. A line counts the new file on the right and the old + * one on the left, and each hunk covers one run of each; a line outside every run — a + * conversation the host could not mark outdated, or one under a hunk it withheld — has no row to + * be pinned to, however much its file looks like a match. + */ +/** + * The pull request's patch, with the review written against it. Conversations already on the + * host sit under the line they were written on, and a new comment joins the review being + * drafted rather than being posted as it is typed. + */ +export function PullRequestCodeTab({ + environmentId, + reference, + detail, + selectedCommitOid, + onSelectedCommitChange, + pendingFinding, + onFixFinding, + onAskAboutSelection, + onRefresh, + refreshToken = 0, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + detail: PullRequestDetail; + /** Commit whose diff is open. Null keeps the whole pull-request diff selected. */ + selectedCommitOid: string | null; + onSelectedCommitChange: (oid: string | null) => void; + /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ + pendingFinding?: string | null; + onFixFinding?: (finding: PullRequestFinding) => void; + /** Absent where a selection has no agent to go to, which takes the Ask button off the box. */ + onAskAboutSelection?: (input: PullRequestAskSelectionInput) => void; + onRefresh: () => void; + /** Bumped by the panel's refresh button: drop the accumulated pages and re-read the diff. */ + refreshToken?: number; +}) { + const { resolvedTheme } = useTheme(); + const settings = useClientSettings(); + const [toggledFiles, setToggledFiles] = useState>(() => new Set()); + // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather + // than a choice. The rest arrive ten at a time, on request. + const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); + /** Set once the reader has asked for every file at once, until they pick a file apart again. */ + const [foldOverride, setFoldOverride] = useState(null); + const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [selectedLines, setSelectedLines] = useState<{ + id: string; + range: SelectedLineRange; + } | null>(null); + const [draft, setDraft] = useState(null); + const [threadPending, setThreadPending] = useState(false); + const [orphansOpen, setOrphansOpen] = useState(false); + // Closed by default so the review form does not permanently eat vertical space below the + // diff; opened on demand as a floating overlay instead. + const [reviewOpen, setReviewOpen] = useState(false); + // Which pull request the slices belong to travels with them, so a render taken before the + // reset below cannot read the previous one's slices — or send its cursor to the host. + const [sliceState, setSliceState] = useState<{ + readonly key: string; + readonly cursor: string | null; + readonly slices: ReadonlyArray; + }>({ key: "", cursor: null, slices: NO_SLICES }); + const parseCache = useRef(new Map()); + + const referenceKey = pullRequestReviewKey(reference); + const commit = selectedCommitOid; + // One commit's own changes and the whole change are two different diffs, paged separately, so + // everything below is keyed by both. + const scopeKey = commit === null ? referenceKey : `${referenceKey}@${commit}`; + // The panel keeps this mounted across pull requests, so an open composer would otherwise + // survive the switch and attach its comment to whichever one is on screen when it is sent. + useEffect(() => { + setDraft(null); + setSelectedLines(null); + setToggledFiles(new Set()); + setFoldOverride(null); + setVisibleCommitCount(COMMIT_PAGE_SIZE); + setOrphansOpen(false); + setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); + parseCache.current.clear(); + }, [scopeKey]); + + const loadedSlices = sliceState.key === scopeKey ? sliceState.slices : NO_SLICES; + const cursor = sliceState.key === scopeKey ? sliceState.cursor : null; + const diffQuery = useEnvironmentQuery( + pullRequestEnvironment.diff({ + environmentId, + input: { + ...reference, + ...(cursor === null ? {} : { cursor }), + ...(commit === null ? {} : { commit }), + }, + }), + ); + // Each answer is kept as its own slice. Concatenating the patches and re-parsing the growing + // text would cost more with every slice, which is the wall the slicing exists to remove. + useEffect(() => { + const data = diffQuery.data; + if (data === null) return; + setSliceState((previous) => { + const slices = previous.key === scopeKey ? previous.slices : NO_SLICES; + const next = { + cursor, + patch: data.patch, + truncated: data.truncated, + nextCursor: data.nextCursor, + }; + const index = slices.findIndex((slice) => slice.cursor === cursor); + if (index === -1) { + return { key: scopeKey, cursor, slices: [...slices, next] }; + } + const existing = slices[index]; + if ( + existing !== undefined && + existing.patch === next.patch && + existing.truncated === next.truncated && + existing.nextCursor === next.nextCursor + ) { + return previous; + } + // A page that came back different means the diff moved under the review. The slices + // after it go with the replacement: their cursors were positions in the old diff. + return { key: scopeKey, cursor, slices: [...slices.slice(0, index), next] }; + }); + }, [cursor, diffQuery.data, scopeKey]); + // The refresh button rereads from the first page rather than the page the reader is on: + // pages are positions in one snapshot of the diff, and a fresh snapshot starts over. + const refreshFirstDiffPage = useAtomRefresh( + pullRequestEnvironment.diff({ + environmentId, + input: { ...reference, ...(commit === null ? {} : { commit }) }, + }), + ); + const appliedRefreshToken = useRef(refreshToken); + useEffect(() => { + if (appliedRefreshToken.current === refreshToken) return; + appliedRefreshToken.current = refreshToken; + setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); + refreshFirstDiffPage(); + }, [refreshToken, scopeKey, refreshFirstDiffPage]); + const reviewKey = referenceKey; + const pendingComments = usePendingReviewComments(reference); + const addComment = usePullRequestReviewStore((store) => store.addComment); + const removeComment = usePullRequestReviewStore((store) => store.removeComment); + const replyToThread = useAtomCommand(pullRequestEnvironment.replyToThread, { + reportFailure: false, + }); + const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { + reportFailure: false, + }); + const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); + const loadDiffFiles = useMemo( + () => + createPullRequestDiffFileContentsLoader(getDiffFileContents, { + environmentId, + reference, + commit, + cacheKey: `pull-request:${referenceKey}:${detail.updatedAt}:${commit ?? "all"}`, + }), + [commit, detail.updatedAt, environmentId, getDiffFileContents, reference, referenceKey], + ); + + // What is offered is the intersection of two different questions: what this host can do at + // all, and what this account may do on this repository. Either one saying no means a control + // that would only ever end in a refusal. + const review = useMemo(() => { + const hostReview = detail.capabilities.review; + const viewer = detail.viewerPermissions; + return { + inlineComment: hostReview.inlineComment && viewer.comment, + reply: hostReview.reply && viewer.comment, + resolve: hostReview.resolve && viewer.resolve, + verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), + }; + }, [detail.capabilities.review, detail.viewerPermissions]); + // A comment is posted against the pull request's head diff, so a line number taken from one + // commit's own diff would land somewhere else entirely. Commenting waits for the whole change. + const canCommentOnLines = review.inlineComment && commit === null; + // Every slice is parsed on its own and the result held, so a slice arriving costs one parse + // rather than one per slice already on screen. Its cache key carries the theme, which is what + // the tokenizer caches against, so a theme change is still a fresh parse. + const parsedSlices = useMemo( + () => + loadedSlices.map((slice) => { + // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a + // key of position alone would keep handing back the parse of the patch it replaced. + const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; + const cached = parseCache.current.get(cacheKey); + if (cached) return cached; + const parsed = getRenderablePatch(slice.patch, cacheKey, { + compactPartialHunkOffsets: true, + }); + if (parsed) parseCache.current.set(cacheKey, parsed); + return parsed; + }), + [loadedSlices, resolvedTheme, scopeKey], + ); + // Sorted within a slice rather than across them: sorting the accumulated set would let a late + // slice push a file the reader is part way through further down the page. + const files = useMemo( + () => + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right)), + ) + : [], + ), + [parsedSlices], + ); + const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; + // What a slice withheld: the host declining to inline part of it, or a patch the viewer could + // not structure and so dropped. Neither says anything about there being more to fetch. + const withheldContent = + loadedSlices.some((slice) => slice.truncated) || + parsedSlices.some((parsed) => parsed?.kind === "raw"); + + // Placing a conversation takes more than its file being in the diff: its line has to fall + // inside a hunk that was rendered. One that does not is drawn nowhere, so it belongs in the + // off-diff list rather than disappearing between the two. + const placedThreadIds = useMemo(() => { + const placed = new Set(); + // A commit's diff counts lines within that commit; a review comment is anchored to the + // pull request's head diff. Pinning one onto the other would put the remark against + // whichever code happens to hold that line number in this commit, so while a commit is on + // screen every conversation is listed rather than placed. + if (commit !== null) return placed; + for (const file of files) { + const path = resolveFileDiffPath(file); + for (const thread of detail.reviewThreads) { + if ( + thread.path === path && + thread.line !== null && + isLineInFileDiff(file, thread.side, thread.line) + ) { + placed.add(thread.id); + } + } + } + return placed; + }, [commit, detail.reviewThreads, files]); + + const items = useMemo[]>( + () => + files.map((fileDiff) => { + const fileKey = buildFileDiffRenderKey(fileDiff); + const path = resolveFileDiffPath(fileDiff); + // One annotation per line, so a line that already carries a conversation shows a new + // comment underneath it rather than in place of it. + const groups = new Map(); + const groupAt = (side: PullRequestDiffSide, line: number) => { + const key = `${side}:${line}`; + const existing = groups.get(key); + if (existing) return existing; + const created: MutableAnnotationGroup = { + side, + line, + threads: [], + pending: [], + draft: false, + }; + groups.set(key, created); + return created; + }; + + for (const thread of detail.reviewThreads) { + if (thread.path !== path || thread.line === null) continue; + if (!placedThreadIds.has(thread.id)) continue; + groupAt(thread.side, thread.line).threads.push(thread); + } + for (const comment of pendingComments) { + if (comment.path !== path) continue; + groupAt(comment.side, comment.line).pending.push(comment); + } + if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + + const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + + const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ + side: toViewerSide(group.side), + lineNumber: group.line, + metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, + })); + return { + id: fileKey, + type: "diff" as const, + fileDiff, + annotations, + collapsed, + // The viewer re-renders an item only when its version changes, so everything the + // annotations show has to be part of it. + version: fnv1a32( + `${collapsed ? "1" : "0"}:${annotations + .map( + ({ side, lineNumber, metadata }) => + `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending + .map((comment) => `${comment.id}:${comment.body}`) + .join(",")}:${metadata.threads + .map( + (thread) => + `${thread.id}:${thread.isResolved ? "r" : ""}:${ + thread.isOutdated ? "o" : "" + }:${thread.comments + .map( + (comment) => + `${comment.id}:${comment.author?.login ?? ""}:${comment.createdAt}:${comment.body}`, + ) + .join(";")}`, + ) + .join(",")}`, + ) + .join("|")}`, + ), + }; + }), + [ + detail.reviewThreads, + draft, + files, + foldOverride, + pendingComments, + placedThreadIds, + toggledFiles, + ], + ); + const lineStat = useMemo(() => getDiffLineStat(files), [files]); + const fileKeys = useMemo(() => items.map((item) => item.id), [items]); + const collapsedFileKeys = useMemo( + () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), + [items], + ); + const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys); + + // The sentinel is held as state rather than a ref because the viewer mounts its own footer: + // an effect reading a ref could run before that node exists and would never arm the observer. + const [sentinel, setSentinel] = useState(null); + useEffect(() => { + // A failed slice must stop the observer. The files already loaded keep the sentinel on + // screen, so re-arming it after a failure would ask for the same slice again, forever. + if ( + sentinel === null || + nextCursor === null || + nextCursor === cursor || + diffQuery.isPending || + diffQuery.error !== null + ) { + return; + } + const observer = new IntersectionObserver( + (observed) => { + if (observed.some((entry) => entry.isIntersecting)) { + setSliceState((previous) => ({ ...previous, cursor: nextCursor })); + } + }, + // Start the next slice slightly before the sentinel is on screen. + { rootMargin: "240px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, sentinel]); + + // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on + // these render props, so a fresh function here would recreate every visible file's portal on + // every tab re-render (a line-selection drag, a keystroke in the draft, a review-store update). + const toggleFile = useCallback( + (fileKey: string) => + setToggledFiles((current) => { + // The override becomes this file's new default the moment it is folded into the set below, + // so nothing has to be re-derived when the reader goes back to choosing one at a time. + const next = new Set(current); + if (next.has(fileKey)) next.delete(fileKey); + else next.add(fileKey); + return next; + }), + [], + ); + + const toggleAllFiles = () => { + // Held as an override of the default rather than as the file keys on screen: a diff that is + // still paging would otherwise bring its next slice in folded, moments after the reader + // asked for everything to be open. + setFoldOverride(areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? "expanded" : "folded"); + setToggledFiles(new Set()); + }; + + // Newest first: the last commit is the one a reader coming back to a change is looking for. + const orderedCommits = useMemo( + () => + // By instant, not by the text: GitLab keeps a commit's own UTC offset, so two commits + // from different zones would sort by how they were written rather than when they landed. + detail.commits.toSorted( + (left, right) => Date.parse(right.committedDate) - Date.parse(left.committedDate), + ), + [detail.commits], + ); + + const beginComment = useCallback( + (range: SelectedLineRange | null, context: { item: CodeViewItem }) => { + if (!range || !canCommentOnLines) return; + const item = context.item; + if (item.type !== "diff") return; + const file = files.find((candidate) => buildFileDiffRenderKey(candidate) === item.id); + if (!file) return; + // A range collapses to its last line: only GitHub carries a multi-line comment, and one + // that silently lost its first line on the other hosts would be worse than one line. + const path = resolveFileDiffPath(file); + const previousPath = resolveFileDiffPreviousPath(file); + setDraft({ + fileKey: item.id, + path, + oldPath: previousPath === path ? null : previousPath, + line: range.end, + side: fromViewerSide(range.endSide ?? range.side), + range, + }); + }, + [canCommentOnLines, files], + ); + + // Built here because the parsed diff only lives here, and built by the same function the + // thread panel's own line selection uses — the gesture is the same one, so a second reading of + // the hunks would only be a second place for it to drift. + const askAboutSelection = useCallback( + (anchor: DraftAnchor, question: string) => { + const file = files.find((candidate) => buildFileDiffRenderKey(candidate) === anchor.fileKey); + const comment = + file === undefined + ? null + : buildDiffReviewComment({ + id: `pull-request-selection:${anchor.fileKey}:${anchor.range.start}:${anchor.range.end}`, + sectionId: `pull-request:${detail.number}`, + sectionTitle: `PR #${detail.number} review`, + filePath: anchor.path, + fileDiff: file, + range: anchor.range, + text: question, + }); + setDraft(null); + setSelectedLines(null); + if (comment === null || !onAskAboutSelection) return; + onAskAboutSelection({ comment, question }); + }, + [detail.number, files, onAskAboutSelection], + ); + + // The viewer's SlotPortals memoizes each visible file's header/annotation portal on these + // render props and on `options` below; a fresh identity on any of them — as a plain inline + // function or object literal would be — invalidates that memo and recreates every portal on + // screen on any tab re-render (a drag-selection, a keystroke in the draft, a review-store + // update), which is the jank this file is otherwise clean of. + const renderCodeViewFooter = useCallback( + () => + // Only while something is still owed. A finished diff whose query fails on a later + // refresh — a reconnect re-runs every one of them — is whole on screen already, and + // saying otherwise sends the reader looking for files that are all there. + nextCursor === null ? null : ( +
+ {diffQuery.error !== null ? ( + <> + The rest of this diff could not be loaded. + + + ) : diffQuery.isPending ? ( + "Loading more files..." + ) : null} +
+ ), + [nextCursor, diffQuery.error, diffQuery.isPending, diffQuery.refresh], + ); + + const renderHeaderPrefix = useCallback( + (item: CodeViewItem) => { + // The item the viewer is drawing already carries the state the memo settled on, so the + // chevron follows it rather than recomputing the default here. + const collapsed = item.collapsed === true; + return ( + + ); + }, + [toggleFile], + ); + + const renderAnnotation = useCallback( + (annotation: ReviewAnnotation) => ( +
+ {annotation.metadata.threads.map(renderThreadCard)} + {annotation.metadata.pending.map((comment) => ( + removeComment(reviewKey, comment.id)} + /> + ))} + {annotation.metadata.draft && draft ? ( + , + allowEmpty: true, + onAction: (question: string) => askAboutSelection(draft, question), + }, + } + : {})} + onCancel={() => { + setDraft(null); + setSelectedLines(null); + }} + onComment={(body) => { + addComment(reviewKey, { + id: nextPendingReviewCommentId(), + path: draft.path, + ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), + line: draft.line, + side: draft.side, + body, + }); + setDraft(null); + setSelectedLines(null); + }} + /> + ) : null} +
+ ), + [draft, reviewKey, removeComment, addComment, onAskAboutSelection, askAboutSelection], + ); + + const diffViewOptions = useMemo( + () => ({ + diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), + lineDiffType: "none" as const, + overflow: wordWrap ? ("wrap" as const) : ("scroll" as const), + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + stickyHeaders: true, + loadDiffFiles, + enableGutterUtility: canCommentOnLines && draft === null, + enableLineSelection: canCommentOnLines && draft === null, + // Two gestures reach the same place: dragging the line numbers selects a range, and the + // gutter's own button comments on the one line it sits on. They are separate callbacks in + // the viewer, so a reader who only ever presses the button gets nothing unless both are + // wired. + onGutterUtilityClick: beginComment, + onLineSelectionEnd: beginComment, + }), + [ + diffRenderMode, + wordWrap, + resolvedTheme, + loadDiffFiles, + canCommentOnLines, + draft, + beginComment, + ], + ); + + const runThreadCommand = async ( + label: string, + run: () => Promise<{ readonly _tag: string }>, + ): Promise => { + if (threadPending) return false; + setThreadPending(true); + const result = await run(); + setThreadPending(false); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: label }); + return false; + } + onRefresh(); + return true; + }; + + // A conversation is the same card wired to the same commands whether it sits on its line or + // was stranded off the diff; only where it is drawn differs. + const renderThreadCard = (thread: PullRequestReviewThread) => ( + onFixFinding({ kind: "thread", thread }) } : {})} + onReply={(body) => + runThreadCommand("Reply could not be posted", () => + replyToThread({ + environmentId, + input: { ...reference, threadId: thread.id, body }, + }), + ) + } + onToggleResolved={() => + void runThreadCommand("The conversation could not be updated", () => + setThreadResolution({ + environmentId, + input: { ...reference, threadId: thread.id, resolved: !thread.isResolved }, + }), + ) + } + /> + ); + + /** + * The review overlay belongs to the pull request, not to the patch: a change whose diff + * cannot be structured — or read at all — is still one a reviewer can approve or reject, so + * it survives every branch below. It floats over the scroll area rather than sitting in the + * layout flow, so the diff keeps the full height instead of permanently losing a strip to a + * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as + * the bar it wraps did. + */ + const reviewOverlay = + review.verdicts.length === 0 ? null : ( +
+ {reviewOpen ? ( +
+ + { + onRefresh(); + setReviewOpen(false); + }} + /> +
+ ) : ( + // Bottom-right, clear of the vertical scrollbar the diff view keeps to its own right + // edge. + + )} +
+ ); + // A rebase or a force-push can take the scoped commit out of the change. Its diff may still + // be reachable on the host, but it is no longer part of what is being reviewed, so the scope + // goes back to the whole change rather than sitting under a name nothing matches. + // + // Including when the change reports no commits at all: the scope dropdown is the only way back + // to the whole diff and it is not drawn without commits to list, so a scope that outlived them + // would leave the tab reading one obsolete commit with nothing to press. + const selectedCommit = orderedCommits.find((entry) => entry.oid === commit); + useEffect(() => { + if (commit !== null && selectedCommit === undefined) { + onSelectedCommitChange(null); + } + }, [commit, onSelectedCommitChange, selectedCommit]); + const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; + /** + * The same controls the thread diff panel carries, in the same order, minus the + * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request + * diff API offers it. + */ + const toolbar = ( +
+
+ {/* A host that reports no commits has nothing to scope by, and a dropdown whose only + entry is the scope already showing is a control that does nothing. */} + {orderedCommits.length > 0 ? ( + + + {scopeLabel} + + + + onSelectedCommitChange(null)} + > + All commits + + {orderedCommits.slice(0, visibleCommitCount).map((entry) => ( + onSelectedCommitChange(entry.oid)} + > + {/* Headlines run long, and the abbreviated oid after one is what a reader + matches against the commit list on the host. */} + + {entry.messageHeadline} + + + {entry.oid.slice(0, 7)} + + + ))} + {orderedCommits.length > visibleCommitCount ? ( + // Kept out of the radio group: it changes how much of the list is on screen + // rather than what the diff is scoped to. + setVisibleCommitCount((count) => count + COMMIT_PAGE_SIZE)} + > + + Show more ({orderedCommits.length - visibleCommitCount} left) + + + ) : null} + + + ) : null} + {/* One count, and the caveats as icons that carry their own words. Spelled out they + competed for a strip this narrow and every one of them truncated to nothing. */} + + + {files.length} {files.length === 1 ? "file" : "files"} + {nextCursor === null ? "" : "+"} + + {withheldContent ? ( + + }> + + + + The host withheld part of this diff — a binary file, or a change too large to + inline. + + + ) : null} + {commit !== null && review.inlineComment ? ( + + }> + + + + A comment is anchored to the whole change, so switch to All commits to write one. + + + ) : null} + +
+
+ + {fileKeys.length > 0 ? ( + + + } + > + {allFilesCollapsed ? ( + + ) : ( + + )} + + + {allFilesCollapsed ? "Expand all files" : "Collapse all files"} + + + ) : null} + { + const next = value[0]; + if (next === "stacked" || next === "split") { + setDiffRenderMode(next); + } + }} + > + + + + + + + + + { + setWordWrap(Boolean(pressed)); + }} + /> + } + > + + + + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + +
+
+ ); + // The toolbar rides above every branch below, not just the one with a patch in it: a commit + // whose diff is empty or unreadable still needs the scope dropdown that got the reader there. + const withReviewBar = (body: ReactNode) => ( +
+ {toolbar} + {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning + inside an overflowing element tracks the content's bottom edge, which would carry + the trigger away with the first scroll. */} +
+
{body}
+ {reviewOverlay} +
+
+ ); + + // Under the toolbar rather than in place of it, so choosing a commit does not take the + // dropdown that was just used off the screen while its diff loads. + if (diffQuery.isPending && loadedSlices.length === 0) { + return withReviewBar(); + } + + // A slice that fails once there are files on screen is reported at the end of them instead: + // the diff already read is worth more than the error that stopped it growing. + if (diffQuery.error && loadedSlices.length === 0) { + return withReviewBar( +

{diffQuery.error}

, + ); + } + + // A patch the viewer cannot structure (binary, or a format it does not parse) still has to + // be readable, so it falls back to the raw text rather than an empty tab. Only once the diff + // is whole: returning here while a cursor is outstanding would take the sentinel off screen + // and end the walk, leaving the rest of the change unasked for. + const rawSlices = + nextCursor === null + ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : [])) + : []; + if (files.length === 0 && rawSlices.length > 0) { + return withReviewBar( +
+ {rawSlices.map((slice) => ( +
+

{slice.reason}

+
{slice.text}
+
+ ))} +
, + ); + } + + if (items.length === 0 && nextCursor === null) { + return withReviewBar( +

+ {commit === null + ? "This pull request has no file changes." + : "This commit has no file changes."} +

, + ); + } + + const orphanThreads = detail.reviewThreads.filter((thread) => !placedThreadIds.has(thread.id)); + // A file carrying five stranded conversations should read as that file once rather than as + // five copies of its path. + const orphanFiles = new Map(); + for (const thread of orphanThreads) { + const existing = orphanFiles.get(thread.path); + if (existing) existing.push(thread); + else orphanFiles.set(thread.path, [thread]); + } + + const unstructured = + rawSlices.length === 0 ? null : ( + // A slice the viewer cannot structure is still part of the change. Shown under the files + // that did parse rather than dropped, because the alternative is a diff that silently + // omits whatever the viewer could not read. +
+ {rawSlices.map((slice) => ( +
+

{slice.reason}

+
{slice.text}
+
+ ))} +
+ ); + + return ( + +
+ {toolbar} + {/* Above the code, closed, and counted: these belong to the change rather than to any + line of it, and in the stream they read as cards dropped into the patch. */} + {orphanFiles.size > 0 ? ( + + {/* Still a heading, so the section keeps its place in a screen reader's outline; + the count is spelled out there rather than left as a bare number. */} +

+ + {/* While slices are still arriving a conversation may simply belong to a file + that has not landed yet, which is not the same as being off the diff. */} + + {nextCursor === null + ? "Conversations not on the current diff" + : "Conversations not on the diff loaded so far"} + + + + {orphanThreads.length} + + + {orphanThreads.length === 1 + ? "1 conversation" + : `${orphanThreads.length} conversations`} + + +

+ + {/* Capped: opened on a change with dozens of them, this would otherwise leave no + room for the diff it sits above. */} +
+ {[...orphanFiles].map(([path, threads]) => ( +
+

+ {path} +

+
+ {threads.map((thread) => ( +
+ {thread.line === null ? null : ( +

Line {thread.line}

+ )} + {renderThreadCard(thread)} +
+ ))} +
+
+ ))} +
+
+
+ ) : null} + {/* Relative wrapper so the review overlay floats over the diff rather than pushing it + up; the viewer inside still owns its own scrolling. */} +
+ {/* The viewer virtualizes against the element it is told is scrolling and places its + rows absolutely, so it has to own that element — the thread diff panel hands it the + same one. Scrolling from a parent instead leaves it painting over its neighbours. */} + + className="h-full overflow-auto" + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={diffViewOptions} + // The viewer owns the scroll container, so the sentinel that asks for the next slice + // has to live inside it — at the end of the files, where reaching it means the reader + // is running out of diff. + renderCodeViewFooter={renderCodeViewFooter} + renderHeaderPrefix={renderHeaderPrefix} + renderAnnotation={renderAnnotation} + /> + {reviewOverlay} +
+ {unstructured} +
+
+ ); +} + +export default PullRequestCodeTab; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx new file mode 100644 index 00000000000..088cfa38751 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -0,0 +1,1153 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + PullRequestAction, + PullRequestMergeMethod, + PullRequestRef, + PullRequestState, +} from "@t3tools/contracts"; +import { + ArrowDownUpIcon, + ArrowLeftIcon, + ArrowUpRightIcon, + BookOpenIcon, + CircleDotIcon, + ChevronDownIcon, + FilesIcon, + FolderGit2Icon, + GitBranchIcon, + GitCommitHorizontalIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + HammerIcon, + MessageCircleQuestionIcon, + MessageSquareIcon, + LinkIcon, + MoreHorizontalIcon, + PanelRightIcon, + RefreshCwIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; + +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import type { ReviewCommentContext } from "~/reviewCommentContext"; +import { useEnvironmentQuery } from "~/state/query"; +import { useLiveRefresh } from "~/hooks/useLiveRefresh"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { toastManager } from "../ui/toast"; +import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; +import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; +import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; +import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; +import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; +import { + buildAskAboutLinesHandoff, + buildAskAboutPullRequestHandoff, + buildExplainPullRequestHandoff, + buildFixFindingHandoff, + buildFixFindingsHandoff, + buildResolveConflictsPrompt, + handoffPrompt, + handoffReviewComments, + pullRequestFindingKey, + readableFailure, + type PullRequestFinding, +} from "./pullRequestDetail.logic"; +import { + PullRequestActorLabel, + PullRequestDiffStat, + PullRequestMetaLine, + resolvePullRequestState, + summarizePullRequestChecks, +} from "./pullRequestPresentation"; + +type DetailTab = "summary" | "timeline" | "code"; + +const ACTION_SUCCESS_LABELS: Record = { + merge: "Pull request merged", + ready: "Marked ready for review", + draft: "Converted to draft", + close: "Pull request closed", + reopen: "Pull request reopened", +}; + +/** Said as the thing that did not happen, rather than as the operation that returned an error. */ +const ACTION_FAILURE_LABELS: Record = { + merge: "Could not merge this pull request", + ready: "Could not mark this ready for review", + draft: "Could not convert this to a draft", + close: "Could not close this pull request", + reopen: "Could not reopen this pull request", +}; + +/** What to try, for the times the host says only that it refused. */ +const ACTION_FAILURE_HINTS: Record = { + merge: + "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting.", + ready: "The host refused it. Check that you have write access to this repository.", + draft: "The host refused it. Check that you have write access to this repository.", + close: "The host refused it. Check that you have write access, or that you opened it.", + reopen: + "The host refused it. Check that you have write access, and that the branch still exists.", +}; + +/** Named for the host rather than "externally": the point is where you will land. */ +const OPEN_ON_HOST_LABELS: Partial> = { + github: "Open on GitHub", + gitlab: "Open on GitLab", + bitbucket: "Open on Bitbucket", + "azure-devops": "Open on Azure DevOps", +}; + +const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ + { value: "summary", label: "Summary" }, + { value: "timeline", label: "Timeline" }, + { value: "code", label: "Code" }, +]; + +// The diff viewer pulls in its worker pool, so it stays out of the bundle until Code is opened. +// Named rather than inlined so the panel can also call it itself, to start the download before +// anyone has clicked the tab. +const loadCodeTab = () => import("./PullRequestCodeTab"); +const PullRequestCodeTab = lazy(loadCodeTab); + +/** + * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it + * is closed by the time the next one opens. It is how a prompt the reader has since edited is told + * apart from the one they were handed: only the sentence still exactly as written may be replaced. + */ +const lastHandoffPromptByDraft = new Map(); + +export function PullRequestDetailPanel({ + environmentId, + reference, + refreshToken: forcedRefreshToken = 0, + onActed, + onClose, + onStateChange, + context = "page", +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + /** + * Bumped by whatever holds the panel when a reader asks for everything on screen to be read + * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, + * and this says it. + */ + refreshToken?: number; + /** + * An action changed this pull request on the host, so a list showing it is now out of date. + * Told rather than assumed: only the page knows whether it is showing one. + */ + onActed?: () => void; + /** Page-owned detail columns use this to clear the selected pull request. */ + onClose?: () => void; + /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */ + onStateChange?: (status: { + projectId: string; + repository: string; + number: number; + state: PullRequestState; + isDraft: boolean; + }) => void; + /** + * Beside a thread, the checkout affordance disappears: the panel is showing that thread's + * own pull request, so the branch is already under the reader's feet — and checking it out + * again is at best a no-op and at worst git refusing a branch two checkouts. + */ + context?: "page" | "thread"; +}) { + const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const [tab, setTab] = useState("summary"); + const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); + const [codeCommitScope, setCodeCommitScope] = useState<{ + readonly pullRequestKey: string; + readonly oid: string | null; + }>(() => ({ pullRequestKey, oid: null })); + const selectedCodeCommitOid = + codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null; + const selectCodeCommit = (oid: string | null) => { + setCodeCommitScope({ pullRequestKey, oid }); + }; + const openCommit = (oid: string) => { + selectCodeCommit(oid); + setTab("code"); + }; + // Every tab the reader has opened stays mounted behind the active one. The diff viewer + // always needed this (it virtualizes against its own scroll position); the trace showed the + // summary needs it too — a large description re-parses its whole markdown on every return + // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content + // out of the tab order and the accessibility tree. + const [mountedTabs, setMountedTabs] = useState>( + () => new Set(["summary"]), + ); + useEffect(() => { + setMountedTabs((previous) => + previous.has(tab) ? previous : new Set(previous).add(tab), + ); + }, [tab]); + const [mergeMethod, setMergeMethod] = useState("merge"); + const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null); + // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself + // alone. One at a time whatever the key: they all check the same pull request out. + const [handoff, setHandoff] = useState(null); + const { copyToClipboard: copyBranchToClipboard, isCopied: isBranchCopied } = useCopyToClipboard({ + target: "branch name", + timeout: 1600, + }); + + // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be + // clicked, so a reader who does click it lands on a chunk already in the module cache. + useEffect(() => { + void loadCodeTab(); + }, []); + + const detailQuery = useEnvironmentQuery( + pullRequestEnvironment.detail({ environmentId, input: reference }), + ); + // Detail and diff are independent server reads, so the diff for the default view (no commit, + // no cursor) is started here too rather than waiting for the Code tab to mount. This is one + // extra cached read per opened pull request even for readers who never open the tab, but it + // turns the tab's first paint from a cold request into a cache hit. + const _diffWarmUpQuery = useEnvironmentQuery( + pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), + ); + const detail = detailQuery.data; + useEffect(() => { + if (!detail) return; + onStateChange?.({ + projectId: detail.projectId, + repository: detail.repository, + number: detail.number, + state: detail.state, + isDraft: detail.isDraft, + }); + }, [detail, onStateChange]); + // A pull request changes while it is open in front of somebody — a push lands, a check + // finishes, a review arrives — so the panel reads it again on the way back to the window and + // while a reader sits on it. Keyed by the pull request rather than by the panel, because this + // one panel shows a different pull request every time it is opened. + useLiveRefresh(() => detailQuery.refresh(), { + key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, + }); + // The button, on the other hand, goes around the server's cache rather than through it: it is + // the answer for a reader who can see that what they are looking at is behind. The + // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run + // and at worst answer from it. + const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [refreshToken, setRefreshToken] = useState(0); + const refreshFromHost = async () => { + await invalidate({ environmentId, input: { reference } }); + detailQuery.refresh(); + setRefreshToken((token) => token + 1); + }; + // A refresh asked for by the page: the detail, and through the token below, the diff with it. + const appliedForcedToken = useRef(forcedRefreshToken); + useEffect(() => { + if (appliedForcedToken.current === forcedRefreshToken) return; + appliedForcedToken.current = forcedRefreshToken; + void refreshFromHost(); + }, [forcedRefreshToken]); + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const [actionPending, setActionPending] = useState(false); + const newThread = useNewThreadHandler(); + const prepareThread = usePreparePullRequestThreadAction({ + environmentId, + cwd: detail?.workspaceRoot ?? null, + }); + + const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { + if (actionPending) return; + setActionPending(true); + const result = await runAction({ + environmentId, + input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, + }); + setActionPending(false); + if (result._tag === "Failure") { + // The host's own sentence, because it is the only thing that says why. A merge strategy a + // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps + // publishes no per-strategy availability to hide the control with — so "action failed" + // would leave the reader pressing the same button again. + const failure = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: ACTION_FAILURE_LABELS[action], + description: readableFailure(failure, ACTION_FAILURE_HINTS[action]), + }); + return; + } + toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); + detailQuery.refresh(); + onActed?.(); + }; + + type ThreadTask = { + prompt: string; + reviewComments?: ReadonlyArray; + }; + + /** + * Opens a thread on this project and leaves the task in its composer for the reader to send. + * + * Nothing is checked out: asking a question is not a reason to move somebody's working tree or + * to make a worktree they did not ask for. The two hand-offs that do need the code call this + * after preparing it, so there is one path from "a task" to "a thread holding it". + */ + const openThreadWithTask = async ( + projectRef: ReturnType, + task: ThreadTask | null, + opened?: { draftId: DraftId }, + ): Promise<{ draftId: DraftId } | null> => { + const session = + opened ?? + (await newThread(projectRef).then( + (result) => result, + () => null, + )); + if (session === null) return null; + const store = useComposerDraftStore.getState(); + if (task === null) return session; + // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips + // both, rather than stacking a second one under the first. What the reader typed themselves + // survives — the composer they are handed is not always a fresh one, and a prompt they have + // since edited is theirs rather than the hand-off's. + const draft = store.getComposerDraft(session.draftId); + const existingComments = draft?.reviewComments ?? []; + const prompt = handoffPrompt( + { + prompt: draft?.prompt ?? "", + lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId), + }, + task.prompt, + ); + // Remember the hand-off's own contribution, not the merged prompt: only that sentence is + // this session's to take back next time, and the reader's text around it is not. + lastHandoffPromptByDraft.set(session.draftId, task.prompt); + store.setPrompt(session.draftId, prompt); + store.setReviewComments( + session.draftId, + handoffReviewComments(existingComments, task.reviewComments ?? []), + ); + return session; + }; + + /** A question about the change, which needs a thread and nothing else. */ + const startAsk = async (kind: string, task: ThreadTask) => { + if (!detail || handoff !== null) return; + setHandoff(kind); + const projectRef = scopeProjectRef(environmentId, detail.projectId); + const opened = await openThreadWithTask(projectRef, task); + setHandoff(null); + if (opened === null) { + toastManager.add({ + type: "error", + title: "Could not open a thread", + description: "Try again from the project, or open a thread first.", + }); + return; + } + toastManager.add({ + type: "success", + title: "Asked in a thread", + // "Ask" leaves the composer empty on purpose, so saying the question is in it would send + // the reader looking for something that is not there. The chips are what landed. + description: + task.prompt.length > 0 + ? "The question is in the composer — read it over, then send." + : "The pull request is in the composer — type your question, then send.", + }); + }; + + // Every handoff works the same way: check the pull request out into its own worktree, open a + // thread there, and — when it carries a task — put that in the composer for the user to read + // before sending. Checking out is the whole point of the ones that carry nothing. + const startHandoff = async ( + kind: string, + task: { prompt: string; reviewComments?: ReadonlyArray } | null, + // A worktree leaves whatever is open alone, which is why it is the default. Checking out in + // the repository itself is what you want when the point is to run the thing where you + // already work — and it moves the branch under everything else that is open there. + mode: "worktree" | "local" = "worktree", + ) => { + if (!detail || handoff !== null) return; + setHandoff(kind); + // The menu closes on the press and takes its "Preparing..." label with it, so this is the + // only thing answering for the checkout. It carries no timeout of its own: a loading toast + // never expires, and an explicit one would survive the update and pin the result on screen. + const toastId = toastManager.add({ + type: "loading", + title: "Preparing the pull request checkout...", + }); + const projectRef = scopeProjectRef(environmentId, detail.projectId); + // The thread is opened before the checkout rather than after it, because the project's setup + // script only runs for a checkout that knows which thread it is for — and a worktree with no + // dependencies installed is not something anyone can test. + const opened = await newThread(projectRef).then( + (session) => session, + () => null, + ); + if (opened === null) { + setHandoff(null); + // Without a thread there is nowhere for the checkout to belong: its setup script would not + // run and its task would have no composer to land in. Better to stop before touching the + // working tree than to prepare a worktree nobody asked for. + toastManager.update(toastId, { + type: "error", + title: "Could not open a thread for the checkout", + description: "Try again from the project, or open a thread first.", + }); + return; + } + const prepared = await prepareThread.run({ + reference: detail.url, + mode, + threadId: opened.threadId, + }); + if (prepared._tag === "Failure") { + setHandoff(null); + // The server says what to do about it — that the branch is already checked out in the main + // repository, say — and that sentence is the only way out of the failure. + const detailMessage = + prepareThread.error instanceof Error ? prepareThread.error.message : null; + toastManager.update(toastId, { + type: "error", + title: "Could not prepare the pull request checkout", + ...(detailMessage ? { description: detailMessage } : {}), + }); + return; + } + // The same thread again, now that there is somewhere to point it at. A local checkout has + // no worktree of its own, so the thread runs where the repository already is. + const pointed = await newThread(projectRef, { + branch: prepared.value.branch, + worktreePath: prepared.value.worktreePath, + envMode: prepared.value.worktreePath === null ? "local" : "worktree", + }).then( + (session) => session !== null, + () => false, + ); + if (!pointed) { + setHandoff(null); + // The checkout is on disk; only the thread failed to move onto it. Writing the task now + // would send the agent at whatever the thread was already open on — which is the one + // outcome worth stopping for, since it reads as success and is not. + toastManager.update(toastId, { + type: "error", + title: "Checked out, but the thread stayed where it was", + description: `The checkout is ready on \`${prepared.value.branch}\`. Point a thread at it from the branch picker, then ask again.`, + }); + return; + } + // Released here whatever happened next: a loading toast never expires on its own, so leaving + // this set would spin forever and lock every handoff behind it until a reload. + setHandoff(null); + // A worktree that was already there and had been worked in keeps whatever it holds, so the + // thread opens on older code than the pull request carries. Said once, in place of the + // success, because everything else about the handoff did happen. + const staleCheckoutToast = { + type: "warning", + title: "Checked out, but not on the latest commits", + description: + "The checkout could not be moved onto the pull request's latest commits, so the code there is older than the pull request. Uncommitted work or local commits keep it where it is.", + } as const; + if (task === null) { + toastManager.update( + toastId, + prepared.value.isOnPullRequestHead + ? { + type: "success", + title: mode === "local" ? "Checked out here" : "Checked out", + description: + mode === "local" + ? "This repository is on the pull request's branch, with a thread open on it." + : "The pull request is in its own worktree, with a thread open on it.", + } + : staleCheckoutToast, + ); + return; + } + await openThreadWithTask(projectRef, task, opened); + toastManager.update( + toastId, + prepared.value.isOnPullRequestHead + ? { + type: "success", + title: "Checkout ready", + description: "The task is in the composer — read it over, then send.", + } + : staleCheckoutToast, + ); + }; + + const askAboutPullRequest = () => { + if (!detail) return; + void startAsk("ask", { + ...buildAskAboutPullRequestHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + }), + }); + }; + + const explainPullRequest = () => { + if (!detail) return; + void startAsk("explain", { + ...buildExplainPullRequestHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + }), + }); + }; + + /** Lines the reader marked in the diff, asked about rather than commented on. */ + const askAboutSelection = (selection: PullRequestAskSelectionInput) => { + if (!detail) return; + void startAsk(`ask:${selection.comment.id}`, { + ...buildAskAboutLinesHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + comment: selection.comment, + question: selection.question, + }), + }); + }; + + const startCheckout = (mode: "worktree" | "local") => { + if (!detail) return; + void startHandoff(`checkout:${mode}`, null, mode); + }; + + /** One finding, handed over on its own — the surfaces that show findings call this. */ + const startFixFinding = (finding: PullRequestFinding) => { + if (!detail) return; + void startHandoff( + pullRequestFindingKey(finding), + buildFixFindingHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + finding, + }), + ); + }; + + const startFixFindings = () => { + if (!detail) return; + void startHandoff( + "findings", + buildFixFindingsHandoff({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + reviewThreads: detail.reviewThreads, + comments: detail.comments, + checks: detail.checks, + commentsTruncated: detail.commentsTruncated, + }), + ); + }; + + const startResolveConflicts = () => { + if (!detail) return; + void startHandoff("conflicts", { + prompt: buildResolveConflictsPrompt({ + number: detail.number, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + }), + }); + }; + + // The host says which strategies it offers at all; the repository narrows that to the ones + // it actually allows. + const allowedMergeMethods = detail + ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) + : []; + const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) + ? mergeMethod + : (allowedMergeMethods[0] ?? "merge"); + const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; + // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden + // until the detail arrives, so the loading ghost is the panel's only unfinished UI. + const visibleTabs = TABS.filter( + (item) => item.value !== "code" || detail === null || detail.capabilities.diff, + ); + // The Code tab can be opened while the detail is still on its way, and the detail may then say + // this host has no patch to show. The tab goes, so whoever was standing on it is moved back to + // the summary rather than left looking at a panel that is no longer reachable. + useEffect(() => { + if (!visibleTabs.some((item) => item.value === tab)) setTab("summary"); + }, [tab, visibleTabs]); + // Two questions, both of which have to say yes: whether this host can do it at all, and + // whether this account may. A reader with read access on someone else's project sees the pull + // request and none of the buttons that would only ever be refused. + const can = (action: PullRequestAction) => + detail?.capabilities.actions.includes(action) === true && + detail.viewerPermissions.actions.includes(action); + // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes + // to the thing that would help instead of a Merge button that only ever says no. + const primaryAction = + detail === null || detail.state !== "open" + ? null + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null + : conflicting + ? "resolve" + : allowedMergeMethods.length > 0 + ? "merge" + : null; + // The pull request number carries this state in the overview and the right-panel tab mirrors + // it. Conflicts keep their own row below: an open pull request remains green there. + const statePresentation = detail + ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) + : null; + const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; + + return ( +
+
+
+ {detail && statePresentation ? ( + <> + + {detail.repository} + + + {/* On the Code tab the tall title/meta block below is hidden so the diff gets the + vertical space; the title rides along in the compact row instead. */} + {tab === "code" ? ( + + {detail.title} + + ) : null} + + ) : null} +
+
+ {detail ? ( + <> + + + + + + void refreshFromHost()}> + + Refresh + + + + + {handoff === "ask" ? "Opening..." : "Ask a question"} + + Opens a thread that knows which pull request you mean. + + + + + + + {handoff === "explain" ? "Opening..." : "Explain this PR"} + + A walk through the diff and what to read closely. + + + + + + {handoff === "findings" ? "Preparing..." : "Fix findings in a thread"} + + + {detail.state === "open" ? ( + <> + {/* Only where the button row could not take it: "Ready for review" on a + draft is the primary header button, so offering it here as well would + show the same action twice. */} + {can(detail.isDraft ? "ready" : "draft") && + !(detail.isDraft && primaryAction === "ready") ? ( + void perform(detail.isDraft ? "ready" : "draft")} + > + {detail.isDraft ? ( + + ) : ( + + )} + {detail.isDraft ? "Ready for review" : "Convert to draft"} + + ) : null} + {/* A preference for the merge action rather than a second action, so it + is a radio group here instead of a chevron welded to the Merge pill. + Hidden while conflicting: every method would fail. */} + {/* Only where merging is on offer at all: a strategy to merge with is not + a choice for someone who may not merge. */} + {can("merge") && + !detail.isDraft && + !conflicting && + allowedMergeMethods.length > 1 ? ( + <> + + + setMergeMethod(method as PullRequestMergeMethod) + } + > + {allowedMergeMethods.map((method) => ( + + {/* The radio item lays its children out as one block, so the + icon and the label need their own row to share a line. */} + + + {method} + + + ))} + + + ) : null} + + + ) : null} + void readLocalApi()?.shell.openExternal(detail.url)}> + + {OPEN_ON_HOST_LABELS[detail.provider] ?? "Open on host"} + + void writeTextToClipboard(detail.url)}> + + Copy link + + {/* Only where the button row could not take it, so it is never offered twice. */} + {conflicting && primaryAction !== "resolve" ? ( + + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts in a thread"} + + ) : null} + {detail.state === "open" && can("close") ? ( + <> + + setConfirmAction("close")} + > + + Close pull request + + + ) : detail.state === "closed" && can("reopen") ? ( + <> + + void perform("reopen")}> + + Reopen pull request + + + ) : null} + + + {/* Checking a pull request out is the reason to open one here at all, so it is a + button of its own rather than a side effect of asking an agent for something. + It asks where, because the two answers are not interchangeable: one leaves your + work where it is, the other moves the repository you are standing in. Only on + the page: beside a thread the branch is already checked out right there. */} + {context === "page" ? ( + + + {handoff?.startsWith("checkout") ? ( + "Checking out..." + ) : ( + <> + + Check out + + + )} + + } + /> + + startCheckout("worktree")}> + + + In a separate worktree + + Its own folder and thread. Nothing you have open moves. + + + + startCheckout("local")}> + + + In this repository + + Switches the branch you are working in, like `gh pr checkout`. + + + + + + ) : null} + {primaryAction === "ready" ? ( + + ) : primaryAction === "merge" ? ( + + ) : null} + + ) : null} + {onClose ? ( + + ) : null} +
+ + {detail && tab !== "code" ? ( +
+

{detail.title}

+ + + updated {formatRelativeTimeLabel(detail.updatedAt)} + + +
+ + {detail.baseBranch} + + + + + + + {detail.changedFiles.toLocaleString()}{" "} + {detail.changedFiles === 1 ? "file" : "files"} + + + +
+
+ ) : null} + + {detail && conflicting ? ( +
+ + + Merge conflicts + + +
+ ) : null} + + {detail ? ( + + ) : null} +
+ +
+ {detailQuery.isPending && !detail ? ( + // The ghost wears the shape of the tab being waited on, so switching tabs mid-load + // does not flash a summary outline under a timeline heading. + tab === "timeline" ? ( + + ) : tab === "code" ? ( + + ) : ( + + ) + ) : detailQuery.error && !detail ? ( + detailQuery.refresh()} + /> + ) : detail ? ( + <> + {mountedTabs.has("summary") ? ( +
+ detailQuery.refresh()} + /> +
+ ) : null} + {mountedTabs.has("timeline") ? ( +
+ +
+ ) : null} + {mountedTabs.has("code") ? ( +
+ }> + detailQuery.refresh()} + refreshToken={refreshToken} + /> + +
+ ) : null} + + ) : null} +
+ + !open && setConfirmAction(null)} + > + + + + {confirmAction === "merge" ? "Merge pull request?" : "Close pull request?"} + + + {confirmAction === "merge" + ? `This merges #${reference.number} using ${selectedMergeMethod}.` + : `This closes #${reference.number} without merging it.`} + + + + }> + Cancel + + + + + +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx new file mode 100644 index 00000000000..9a027b50c4e --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -0,0 +1,122 @@ +/** + * Loading states specific to the pull request surface — the first list, a search under way, + * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing + * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. + * + * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — + * compositor-safe, but a layer for every bar on screen — and its white highlight over the + * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the + * container is a single opacity animation however many bars sit under it, and the bars take + * their tone from `muted-foreground` at low alpha, which reads on both themes. + */ +import { cn } from "~/lib/utils"; + +function GhostBar({ className }: { className?: string | undefined }) { + return
; +} + +/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ +const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"]; +const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"]; + +/** Rows in the list's own grid — glyph, title over meta, time over diffstat. */ +export function PullRequestListGhost({ + rows = 7, + caption, +}: { + rows?: number; + /** Said where the group headers speak, for the states with something to say — a search. */ + caption?: string; +}) { + return ( +
+ {caption ? ( +

{caption}

+ ) : null} + {Array.from({ length: rows }, (_, index) => ( +
+ +
+ + +
+
+ + +
+
+ ))} +
+ ); +} + +/** The summary's own shape: a title, a byline, the facts rows, the description. */ +export function PullRequestDetailGhost() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }, (_, index) => ( +
+ + + +
+ ))} +
+
+ + + + +
+
+ ); +} + +/** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ +export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }, (_, index) => ( +
+ + +
+ ))} +
+ ); +} + +/** The timeline's own shape: dots on the rail, a line and a date to each. */ +export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { + return ( +
+
+ {Array.from({ length: rows }, (_, index) => ( +
+ + + +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx new file mode 100644 index 00000000000..cef54e639d2 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx @@ -0,0 +1,54 @@ +/** + * Which of the four states wins, and which of them offer to ask the hosts again. The component is + * called as a plain function and its tree read for text: the elements are walked rather than + * invoked, so the button's own hooks never run outside a render. + */ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it } from "vite-plus/test"; + +import { PullRequestListEmptyState } from "./PullRequestListEmptyState"; + +function textOf(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textOf).join(" "); + if (!isValidElement(node)) return ""; + return textOf((node as ReactElement<{ children?: ReactNode }>).props.children); +} + +const baseProps = { + query: "", + filtered: false, + searching: false, + hasProjects: true, + canLoadMore: false, + loadingMore: false, + refreshing: false, + onClearQuery: () => {}, + onLoadMore: () => {}, + onRefresh: () => {}, +}; + +function render(props: Partial): string { + return textOf(PullRequestListEmptyState({ ...baseProps, ...props })); +} + +describe("PullRequestListEmptyState", () => { + it("asks for a project ahead of anything a search or a filter could say", () => { + const text = render({ hasProjects: false, searching: true, query: "fix", filtered: true }); + expect(text).toContain("No projects in this workspace"); + expect(text).toContain("Add project"); + }); + + it("leaves the retry off the states where asking again could not change the answer", () => { + expect(render({ hasProjects: false })).not.toContain("Check again"); + expect(render({ searching: true, query: "fix" })).not.toContain("Check again"); + }); + + it("offers the retry once the hosts have answered", () => { + expect(render({})).toContain("Check again"); + expect(render({ filtered: true })).toContain("Check again"); + expect(render({ query: "fix" })).toContain("Check again"); + expect(render({ canLoadMore: true })).toContain("Load more pull requests"); + expect(render({ refreshing: true })).toContain("Checking..."); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx new file mode 100644 index 00000000000..4dd92dbf224 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx @@ -0,0 +1,184 @@ +/** + * What the list shows when it has no rows to show. + * + * The drawing is the page's own subject rather than a stock empty box: two branch lines and the + * node where a change would land, in the stroke language the row icons already use. Nothing + * found leaves the branch unjoined — the gap is the whole picture, so it is drawn once and the + * variants only decide whether the seam closes. + * + * An empty page and an unread one look the same, so the states that are showing a host's answer + * offer to ask for it again. The two that are not — a search still in flight, and a workspace + * with no project to read from — leave the button out, since pressing it could only repeat what + * is already happening or ask nobody. + */ +import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; + +import { openCommandPalette } from "../../commandPaletteBus"; +import { Button } from "../ui/button"; +import { PullRequestListGhost } from "./PullRequestGhosts"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; + +/** + * Drawn at the weight of the icons beside it rather than as an illustration with its own + * palette, so an empty page reads as the same surface with nothing on it. + */ +function BranchMark({ joined }: { joined: boolean }) { + return ( + + {/* The base line the change would land on, always whole. */} + + + + {joined ? ( + // A branch that leaves the base and comes back: the shape of a change that landed. + + ) : ( + <> + {/* The same branch, stopped short. What is missing is the join, so that is what the + drawing withholds. */} + + + + )} + + + ); +} + +export function PullRequestListEmptyState({ + query, + filtered, + searching, + hasProjects, + canLoadMore, + loadingMore, + refreshing, + onClearQuery, + onLoadMore, + onRefresh, +}: { + /** The text being searched for, so the reader is told what was searched rather than guessing. */ + query: string; + /** True when a state, involvement or project filter is narrowing the list. */ + filtered: boolean; + /** A search is in flight; the rows on screen are the previous answer. */ + searching: boolean; + /** + * Whether this environment holds a project at all. The list is assembled from the projects' + * remotes, so without one there is no host to ask and no filter or search that could help. + */ + hasProjects: boolean; + canLoadMore: boolean; + loadingMore: boolean; + /** A re-read of the hosts is already running, from here or from the header. */ + refreshing: boolean; + onClearQuery: () => void; + onLoadMore: () => void; + onRefresh: () => void; +}) { + // Ahead of the search and the filters, because neither can produce a row until a project does. + if (!hasProjects) { + return ( + + + + No projects in this workspace + + Add a project, and the pull requests from its repository appear here. + + + + + + + ); + } + + if (searching) { + // The same ghost the first load wears, so a search on its way and a list on its way are + // one state to the eye — with the question named where the group headers usually speak. + return ( + 48 ? `${query.slice(0, 48)}…` : query}”`} + /> + ); + } + + if (query.length > 0) { + return ( + + + + {/* A pasted paragraph is still a search, but it is not a title. */} + + Nothing matches “{query.length > 48 ? `${query.slice(0, 48)}…` : query}” + + + The hosts were searched for it. Try fewer words, or search by number, author or branch. + + + + + {/* The hosts answered this query once; a pull request opened since then would answer + differently, and nothing on screen says which of the two the reader is looking at. */} + + + + ); + } + + return ( + + + + {filtered ? "Nothing under these filters" : "No pull requests"} + + {filtered + ? "Widen the state, involvement or project filter to see more." + : "Pull requests from every project in this workspace appear here."} + + + + {canLoadMore ? ( + + ) : null} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx new file mode 100644 index 00000000000..545e3066f81 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -0,0 +1,99 @@ +import type { ProjectId } from "@t3tools/contracts"; +import { CircleIcon } from "lucide-react"; +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PullRequestFiltersMenu } from "./PullRequestListFilters"; + +function findValueChange( + node: ReactNode, +): + | ReactElement<{ readonly children?: ReactNode; readonly onValueChange: (value: string) => void }> + | undefined { + for (const child of Children.toArray(node)) { + if (!isValidElement(child)) continue; + const props = child.props as { + readonly children?: ReactNode; + readonly onValueChange?: (value: string) => void; + }; + if (props.onValueChange) { + return child as ReactElement<{ + readonly children?: ReactNode; + readonly onValueChange: (value: string) => void; + }>; + } + const nested = findValueChange(props.children); + if (nested) return nested; + } + return undefined; +} + +/** The nested radio-group component element carrying this label, invoked so its group shows. */ +function findLabeledGroup(node: ReactNode, label: string): ReactNode { + for (const child of Children.toArray(node)) { + if (!isValidElement(child)) continue; + const props = child.props as { readonly children?: ReactNode; readonly label?: string }; + if (props.label === label && typeof child.type === "function") { + return (child.type as (properties: unknown) => ReactNode)(child.props); + } + const nested = findLabeledGroup(props.children, label); + if (nested !== undefined) return nested; + } + return undefined; +} + +function menu(overrides: Partial[0]>) { + return PullRequestFiltersMenu({ + state: "open", + stateOptions: [ + { value: "open", label: "Open", Icon: CircleIcon }, + { value: "closed", label: "Closed", Icon: CircleIcon }, + ], + onState: () => undefined, + involvement: "all", + involvementOptions: [{ value: "all", label: "All", Icon: CircleIcon }], + onInvolvement: () => undefined, + host: undefined, + hostOptions: [], + onHost: () => undefined, + environmentId: null, + projects: [], + projectId: undefined, + unavailable: new Map(), + onProject: () => undefined, + ...overrides, + }); +} + +describe("pull request filters menu", () => { + it("does not emit a change when the selected state is chosen again", () => { + const onState = vi.fn(); + const group = findValueChange(findLabeledGroup(menu({ onState }), "State")); + expect(group).toBeDefined(); + + group?.props.onValueChange("open"); + expect(onState).not.toHaveBeenCalled(); + + group?.props.onValueChange("closed"); + expect(onState).toHaveBeenCalledOnce(); + expect(onState).toHaveBeenCalledWith("closed"); + }); + + it("does not emit a change when the selected project is chosen again", () => { + const projectId = "project-1" as ProjectId; + const onProject = vi.fn(); + const view = menu({ + projects: [{ id: projectId, title: "T3 Code", workspaceRoot: "/work/t3code" }], + projectId, + onProject, + }); + const radioGroup = findValueChange(view); + expect(radioGroup).toBeDefined(); + + radioGroup?.props.onValueChange(projectId); + expect(onProject).not.toHaveBeenCalled(); + + radioGroup?.props.onValueChange("all"); + expect(onProject).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx new file mode 100644 index 00000000000..cb95a5be35b --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -0,0 +1,288 @@ +import type { + EnvironmentId, + ProjectId, + PullRequestInvolvement, + PullRequestListState, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { FolderGit2Icon, LayersIcon, ListFilterIcon, LoaderIcon, SearchIcon } from "lucide-react"; +import type { ElementType } from "react"; + +import { cn } from "~/lib/utils"; +import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +import { ProjectFavicon } from "../ProjectFavicon"; + +import { + Menu, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; + +export interface PullRequestFilterOption { + readonly value: Value; + readonly label: string; + /** + * Carries the option's own tone, so an icon reads the same here as it does on a row. Left + * uncoloured, which lets the item's selected state stay the thing the eye follows. + */ + readonly Icon: ElementType<{ className?: string }>; + /** Why it cannot be chosen, carried onto the item as its title. */ + readonly unavailable?: string | undefined; +} + +export interface PullRequestExpectedHost { + readonly host: string; + readonly kind: SourceControlProviderKind; +} + +/** + * What to call a host in the row. The provider's own name reads best — "GitHub" over + * "github.com" — but it stops naming anything once a workspace has two hosts of one kind, so + * those wear the host itself instead. Only the ambiguous ones: a lone GitLab beside two GitHub + * installs is still "GitLab". + */ +export function pullRequestHostLabel( + entries: ReadonlyArray<{ readonly host: string; readonly kind: SourceControlProviderKind }>, + entry: { readonly host: string; readonly kind: SourceControlProviderKind }, +): string { + const sharing = entries.filter((candidate) => candidate.kind === entry.kind); + return sharing.length > 1 + ? entry.host + : getSourceControlPresentationForKind(entry.kind).providerName; +} + +export function PullRequestSearchInput({ + value, + busy, + onChange, +}: { + value: string; + /** A search is on its way to the hosts, said where the typing is rather than over the list. */ + busy?: boolean; + onChange: (value: string) => void; +}) { + return ( +
+ {busy ? ( + + ) : ( + + )} + onChange(event.currentTarget.value)} + placeholder="Search pull requests" + aria-label="Search pull requests" + // Tracks the shared input's height at both widths, so it stays level with the icon + // button beside it rather than towering over it on wide screens. + className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:h-8" + /> +
+ ); +} + +/** + * Every list filter lives behind the one filter icon so the control row stays two controls + * wide: the search and this. The trigger carries a dot whenever any filter is off its + * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's + * actions, which also owns its own spacing. + */ +const ALL_PROJECTS_VALUE = "all"; +/** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ +const ALL_HOSTS_VALUE = ""; + +function PullRequestFilterRadioGroup({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + return ( + { + if (next !== value) onChange(next as Value); + }} + > + {label} + {options.map((option) => ( + + + + {option.label} + + + ))} + + ); +} + +export function PullRequestFiltersMenu({ + state, + stateOptions, + onState, + involvement, + involvementOptions, + onInvolvement, + host, + hostOptions, + onHost, + environmentId, + projects, + projectId, + unavailable, + onProject, +}: { + state: PullRequestListState; + stateOptions: ReadonlyArray>; + onState: (state: PullRequestListState) => void; + involvement: PullRequestInvolvement; + involvementOptions: ReadonlyArray>; + onInvolvement: (involvement: PullRequestInvolvement) => void; + host: string | undefined; + /** + * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real + * hosts there is nothing to switch between, so the whole group stays out of the menu. + */ + hostOptions: ReadonlyArray>; + onHost: (host: string | undefined) => void; + /** Where the projects' own favicons are read from; null before the environment is known. */ + environmentId: EnvironmentId | null; + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; + }>; + projectId: ProjectId | undefined; + /** + * Projects whose repository could not be read this time round. They are named here, where + * the reader is already choosing between projects, rather than as a count above the list + * that says something is missing without saying which. + */ + unavailable: ReadonlyMap; + onProject: (projectId: ProjectId | undefined) => void; +}) { + const filtered = + state !== "open" || involvement !== "all" || host !== undefined || projectId !== undefined; + return ( + + + + {filtered ? ( + + ) : null} + + + + + + {hostOptions.length > 2 ? ( + <> + + onHost(next === ALL_HOSTS_VALUE ? undefined : next)} + /> + + ) : null} + + { + const nextProjectId = next === ALL_PROJECTS_VALUE ? undefined : (next as ProjectId); + if (nextProjectId !== projectId) onProject(nextProjectId); + }} + > + Project + + + + All projects + + + {/* The ones that can be chosen first: a list that opens with three disabled rows reads + as a broken menu rather than as a workspace with three unreadable repositories. */} + {projects + .toSorted( + (left, right) => Number(unavailable.has(left.id)) - Number(unavailable.has(right.id)), + ) + .map((project) => { + const reason = unavailable.get(project.id); + return ( + + + {environmentId === null ? ( + + ) : ( + + )} + {project.title} + {reason === undefined ? null : ( + + Unavailable + + )} + + + ); + })} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx new file mode 100644 index 00000000000..46aa44dc128 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -0,0 +1,58 @@ +import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +import ChatMarkdown from "../ChatMarkdown"; +import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +/** + * A pull request body, rendered with the app's markdown renderer plus a card for each upload + * embedded in it, which that renderer drops on the floor. + * + * The card links out instead of playing in place, because nothing here can play. A + * `github.com/user-attachments/assets/…` link is a 302 to a signed S3 URL that serves the file + * as uploaded — `video/quicktime` for anything recorded on a Mac, which no Chromium decodes — + * and the desktop window's content policy declares no `media-src`, so media falls back to + * `default-src 'self'` and every remote source is refused before a byte is fetched. A player + * here can only be the box that never fills in; a card that opens the host is a real answer. + */ +export function PullRequestMarkdown({ + text, + cwd, + className, +}: { + text: string; + cwd: string; + className?: string; +}) { + const segments = splitPullRequestBody(text); + return ( +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx new file mode 100644 index 00000000000..03fc512d850 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -0,0 +1,236 @@ +/** + * Pull-request-specific annotations: conversations already on the host and comments queued for + * the review being written. New comment composition uses the shared diff annotation. + */ +import type { PullRequestReviewThread } from "@t3tools/contracts"; +import { + CheckCircle2Icon, + CircleIcon, + HammerIcon, + MessageSquareIcon, + Trash2Icon, +} from "lucide-react"; +import { useRef, useState } from "react"; + +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { cn } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut"; +import { PullRequestActorLabel } from "./pullRequestPresentation"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; +import type { PendingReviewComment } from "./pullRequestReviewStore"; + +const CARD_CLASS = + "mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 text-sm shadow-sm"; + +/** Sends a reply on ⌘/Ctrl+Enter and abandons it on Escape. */ +function submitKeys(input: { + readonly value: string; + readonly pending: boolean; + readonly onSubmit: () => void; + readonly onCancel?: (() => void) | undefined; +}) { + return (event: React.KeyboardEvent) => { + if (event.key === "Escape" && input.onCancel) { + event.preventDefault(); + input.onCancel(); + } + if (isCommentSubmitShortcut(event, input.value, input.pending)) { + event.preventDefault(); + input.onSubmit(); + } + }; +} + +/** A comment waiting to be sent with the rest of the review. */ +export function PendingReviewCommentCard({ + comment, + onRemove, +}: { + comment: PendingReviewComment; + onRemove: () => void; +}) { + return ( +
event.stopPropagation()} + > +
+ + Pending — sent when you submit the review + +
+

{comment.body}

+
+ ); +} + +/** A conversation already on the host, with whatever this host lets the reader do to it. */ +export function ReviewThreadCard({ + thread, + workspaceRoot, + canReply, + canResolve, + pending, + fixPending, + onFix, + onReply, + onToggleResolved, +}: { + thread: PullRequestReviewThread; + workspaceRoot: string; + canReply: boolean; + canResolve: boolean; + pending: boolean; + /** True while this thread's own hand-off is preparing, so only its button says so. */ + fixPending?: boolean; + /** Absent where a thread is shown outside the pull request page's reach. */ + onFix?: () => void; + /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */ + onReply: (body: string) => Promise; + onToggleResolved: () => void; +}) { + // A resolved thread is finished work, so it opens collapsed and stays one line until asked for. + const [expanded, setExpanded] = useState(!thread.isResolved); + const [replying, setReplying] = useState(false); + const [reply, setReply] = useState(""); + const sendingRef = useRef(false); + + const send = async () => { + const trimmed = reply.trim(); + if (trimmed.length === 0 || pending || sendingRef.current) return; + sendingRef.current = true; + // Cleared only once the host has it. Otherwise a failed reply leaves an error toast and an + // empty box, and the words have to be written again. + try { + if (await onReply(trimmed)) { + setReply(""); + setReplying(false); + } + } finally { + sendingRef.current = false; + } + }; + + return ( +
event.stopPropagation()} + > +
+ {thread.isResolved ? ( + + ) : ( + + )} + + {thread.isOutdated ? outdated : null} + {onFix ? ( + + ) : null} + {canResolve ? ( + + ) : null} +
+ + {expanded ? ( + <> +
+ {thread.comments.map((comment) => ( +
+
+ + {formatRelativeTimeLabel(comment.createdAt)} +
+ +
+ ))} +
+ + {canReply ? ( + replying ? ( +
+