From 47da51f980eb4b932b327331b03061ab7a2b881c Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:05:11 -0400 Subject: [PATCH 1/5] feat(web): add a pull requests page and sidebar row Threadlines had no place to see the pull requests of a workspace. Every GitHub project's PRs now list on one page, grouped by what needs the user, with each row tied to the thread that produced it and a way to hand a PR to a new thread. A Pull Requests row under General Chats opens it and shows how many PRs need you. The server reads through gh once per repository and caches for 30 seconds; the capability is advertised so older servers hide the row. --- .../environment/Layers/ServerEnvironment.ts | 1 + .../pullRequest/PullRequestService.test.ts | 323 +++++++++ .../src/pullRequest/PullRequestService.ts | 348 ++++++++++ .../pullRequest/gitHubPullRequestList.test.ts | 112 ++++ .../src/pullRequest/gitHubPullRequestList.ts | 243 +++++++ apps/server/src/server.test.ts | 10 +- apps/server/src/server.ts | 10 +- apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.browser.tsx | 1 + apps/web/src/components/CommandPalette.tsx | 18 + apps/web/src/components/Sidebar.tsx | 80 ++- .../PullRequestsView.browser.tsx | 260 +++++++ .../pull-requests/PullRequestsView.tsx | 632 ++++++++++++++++++ .../pull-requests/pullRequests.logic.test.ts | 323 +++++++++ .../pull-requests/pullRequests.logic.ts | 334 +++++++++ .../settings/CompactVersionAdvisory.tsx | 14 +- .../settings/ExtensionsSettings.tsx | 35 +- .../sidebar/SidebarPullRequestsRow.tsx | 73 ++ apps/web/src/components/ui/page-tabs.tsx | 42 ++ apps/web/src/environmentApi.ts | 3 + apps/web/src/lib/externalLinks.ts | 18 + apps/web/src/lib/pullRequestsReactQuery.ts | 231 +++++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.pull-requests.tsx | 25 + apps/web/src/rpc/wsRpcClient.ts | 10 + docs/design/pull-requests.md | 271 ++++++++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/pullRequest.ts | 119 ++++ packages/contracts/src/rpc.ts | 15 + 31 files changed, 3498 insertions(+), 87 deletions(-) create mode 100644 apps/server/src/pullRequest/PullRequestService.test.ts create mode 100644 apps/server/src/pullRequest/PullRequestService.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestList.test.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestList.ts create mode 100644 apps/web/src/components/pull-requests/PullRequestsView.browser.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestsView.tsx create mode 100644 apps/web/src/components/pull-requests/pullRequests.logic.test.ts create mode 100644 apps/web/src/components/pull-requests/pullRequests.logic.ts create mode 100644 apps/web/src/components/sidebar/SidebarPullRequestsRow.tsx create mode 100644 apps/web/src/components/ui/page-tabs.tsx create mode 100644 apps/web/src/lib/externalLinks.ts create mode 100644 apps/web/src/lib/pullRequestsReactQuery.ts create mode 100644 apps/web/src/routes/_chat.pull-requests.tsx create mode 100644 docs/design/pull-requests.md create mode 100644 packages/contracts/src/pullRequest.ts diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 928e12dca..77ff744ba 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -84,6 +84,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function serverVersion: serverConfig.appVersion, capabilities: { repositoryIdentity: true, + pullRequests: true, }, }; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts new file mode 100644 index 000000000..e8dfd073d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, afterEach, describe, 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 { + ProjectId, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, +} from "@threadlines/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const authStatusOutput = (login: string) => + processOutput( + JSON.stringify({ + hosts: { "github.com": [{ state: "success", active: true, host: "github.com", login }] }, + }), + ); + +const project = (input: { + readonly id: string; + readonly title: string; + readonly provider: string; + readonly repository: string; +}): OrchestrationProjectShell => { + const [owner = "", name = ""] = input.repository.split("/"); + return { + id: ProjectId.make(input.id), + kind: "workspace", + title: input.title, + workspaceRoot: `/workspaces/${name}`, + repositoryIdentity: { + canonicalKey: `${input.provider}:${input.repository}`, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://example.com/${input.repository}.git`, + }, + provider: input.provider, + owner, + name, + }, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; +}; + +const pullRequestRow = (input: { + readonly number: number; + readonly author: string; + readonly reviewRequests?: ReadonlyArray>; +}) => ({ + number: input.number, + title: `Pull request ${input.number}`, + url: `https://github.com/octocat/example-app/pull/${input.number}`, + author: { login: input.author, is_bot: false }, + headRefName: `feature/${input.number}`, + baseRefName: "main", + state: "OPEN", + mergedAt: null, + isDraft: false, + additions: 1, + deletions: 0, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-31T10:00:00Z", + reviewDecision: "", + reviewRequests: input.reviewRequests ?? [], + labels: [], +}); + +const mockExecute = vi.fn(); +const mockGetShellSnapshot = vi.fn<() => Effect.Effect>(); + +const layer = PullRequestService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(GitHubCli.GitHubCli)({ execute: mockExecute }), + Layer.mock(ProjectionSnapshotQuery)({ getShellSnapshot: mockGetShellSnapshot }), + ), + ), +); + +const withProjects = (projects: ReadonlyArray) => { + mockGetShellSnapshot.mockReturnValue( + Effect.succeed({ + snapshotSequence: 0, + projects, + threads: [], + updatedAt: "2026-08-01T00:00:00.000Z", + }), + ); +}; + +const repositoryArg = (args: ReadonlyArray) => { + const index = args.indexOf("--repo"); + return index < 0 ? null : (args[index + 1] ?? null); +}; + +const prListCalls = () => + mockExecute.mock.calls.filter(([input]) => input.args[0] === "pr").map(([input]) => input); + +afterEach(() => { + mockExecute.mockReset(); + mockGetShellSnapshot.mockReset(); +}); + +describe("PullRequestService.list", () => { + it.effect("lists GitHub projects and silently skips the others", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-app", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + project({ + id: "project-tools", + title: "Tools", + provider: "gitlab", + repository: "octocat/tools", + }), + ]); + mockExecute.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.succeed(authStatusOutput("octocat")) + : Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), + ), + ); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [entry.projectId, entry.repository, entry.number]), + [["project-app", "octocat/example-app", 1]], + ); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + prListCalls().map((input) => repositoryArg(input.args)), + ["octocat/example-app"], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads a repository once when several projects point at it", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-checkout", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + project({ + id: "project-worktree", + title: "Example App worktree", + provider: "github", + repository: "Octocat/Example-App", + }), + ]); + mockExecute.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.succeed(authStatusOutput("octocat")) + : Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 7, author: "hubot" })])), + ), + ); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [entry.projectId, entry.number]), + [["project-checkout", 7]], + ); + assert.deepStrictEqual( + prListCalls().map((input) => repositoryArg(input.args)), + ["octocat/example-app"], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks the viewer's own pull requests and pending review requests", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-app", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + ]); + mockExecute.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.succeed(authStatusOutput("octocat")) + : Effect.succeed( + processOutput( + JSON.stringify([ + pullRequestRow({ number: 1, author: "OctoCat" }), + pullRequestRow({ + number: 2, + author: "hubot", + reviewRequests: [{ __typename: "User", login: "octocat" }], + }), + pullRequestRow({ + number: 3, + author: "hubot", + reviewRequests: [{ __typename: "Team", name: "core", slug: "core" }], + }), + ]), + ), + ), + ); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.equal(result.viewer, "octocat"); + assert.deepStrictEqual( + result.entries.map((entry) => [ + entry.number, + entry.viewerIsAuthor, + entry.viewerReviewRequested, + ]), + [ + [1, true, false], + [2, false, true], + [3, false, false], + ], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reports one failing project and still returns the others", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-app", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + project({ + id: "project-site", + title: "Marketing Site", + provider: "github", + repository: "octocat/site", + }), + ]); + mockExecute.mockImplementation((input) => { + if (input.args[0] === "auth") { + return Effect.succeed(authStatusOutput("octocat")); + } + return repositoryArg(input.args) === "octocat/site" + ? Effect.fail( + new GitHubCli.GitHubCliError({ + operation: "execute", + detail: "You are not logged into any GitHub hosts. Run gh auth login.", + }), + ) + : Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), + ); + }); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0]?.projectId, "project-site"); + assert.equal(result.errors[0]?.repository, "octocat/site"); + assert.equal(result.errors[0]?.reason, "unauthenticated"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("serves a repeated listing from the cache until force asks for a fresh read", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-app", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + ]); + mockExecute.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.succeed(authStatusOutput("octocat")) + : Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), + ), + ); + + const service = yield* PullRequestService.PullRequestService; + yield* service.list({ state: "open" }); + yield* service.list({ state: "open" }); + expect(prListCalls()).toHaveLength(1); + + yield* service.list({ state: "open", force: true }); + expect(prListCalls()).toHaveLength(2); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 000000000..2d6ead89e --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,348 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import { + ProjectId, + PullRequestServiceError, + type OrchestrationProjectShell, + type PullRequestListEntry, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListProjectErrorReason, + type PullRequestListResult, + type PullRequestListState, +} from "@threadlines/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, +} from "../sourceControl/gitHubAuthStatus.ts"; +import { + decodeGitHubPullRequestListJson, + formatGitHubPullRequestListDecodeError, + GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD, + GITHUB_PULL_REQUEST_LIST_FIELDS, + type GitHubPullRequestListRow, +} from "./gitHubPullRequestList.ts"; + +const GITHUB_HOST = "github.com"; +const PROJECT_CONCURRENCY = 4; +const OPEN_LIST_LIMIT = 50; +const SETTLED_LIST_LIMIT = 30; +/** The page refreshes on an interval, so a short shared cache keeps `gh` off the host. */ +const LIST_CACHE_TTL = Duration.seconds(30); +const LIST_CACHE_CAPACITY = 32; +/** The signed-in account changes far more rarely than the listings do. */ +const VIEWER_CACHE_TTL = Duration.minutes(10); +const VIEWER_CACHE_CAPACITY = 4; + +export interface PullRequestServiceShape { + readonly list: ( + input: PullRequestListInput, + ) => Effect.Effect; +} + +export class PullRequestService extends Context.Service< + PullRequestService, + PullRequestServiceShape +>()("threadlines/pullRequest/PullRequestService") {} + +/** One project the listing can read, already resolved to an `owner/name` repository. */ +interface PullRequestProject { + readonly projectId: ProjectId; + readonly title: string; + readonly workspaceRoot: string; + readonly repository: string; +} + +interface PullRequestListCacheKey { + readonly state: PullRequestListState; + readonly projectId?: ProjectId; +} + +/** What one project contributed to a listing: its rows, or the reason it failed. */ +interface PullRequestProjectRead { + readonly entries: ReadonlyArray; + readonly error: PullRequestListProjectError | null; +} + +const listCacheKey = (key: PullRequestListCacheKey) => `${key.state}|${key.projectId ?? "*"}`; + +/** Inverse of {@link listCacheKey}; only ever reads keys that function produced. */ +function parseListCacheKey(key: string): PullRequestListCacheKey { + const separatorIndex = key.indexOf("|"); + const rawState = key.slice(0, separatorIndex); + const rawProjectId = key.slice(separatorIndex + 1); + const state: PullRequestListState = + rawState === "merged" ? "merged" : rawState === "closed" ? "closed" : "open"; + return { + state, + ...(rawProjectId === "*" ? {} : { projectId: ProjectId.make(rawProjectId) }), + }; +} + +/** + * Only workspace projects with a resolved GitHub `owner/name` can be listed. + * Everything else is skipped silently: a Bitbucket project or a general chat is + * not a failure the user needs to see. + */ +function toPullRequestProject(project: OrchestrationProjectShell): PullRequestProject | null { + if (project.kind === "general-chat") { + return null; + } + + const identity = project.repositoryIdentity; + if (!identity || identity.provider !== "github") { + return null; + } + + const owner = identity.owner?.trim() ?? ""; + const name = identity.name?.trim() ?? ""; + if (owner.length === 0 || name.length === 0) { + return null; + } + + return { + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repository: `${owner}/${name}`, + }; +} + +/** + * One read per repository. A checkout and its worktrees are separate projects + * pointing at the same remote, and the host would answer each of them with the + * same rows. The first project keeps the seat; GitHub repository names are + * case-insensitive, so the key is too. + */ +function dedupeProjectsByRepository( + projects: ReadonlyArray, +): ReadonlyArray { + const byRepository = new Map(); + for (const project of projects) { + const key = project.repository.toLowerCase(); + if (!byRepository.has(key)) { + byRepository.set(key, project); + } + } + return [...byRepository.values()]; +} + +/** Turns a `gh` failure into the reason the page renders an action for. */ +function classifyPullRequestListFailure(detail: string): PullRequestListProjectErrorReason { + const lower = detail.toLowerCase(); + if ( + lower.includes("not available on path") || + lower.includes("command not found") || + lower.includes("enoent") + ) { + return "missing-tool"; + } + if ( + lower.includes("not logged in") || + lower.includes("not authenticated") || + lower.includes("authentication") || + lower.includes("auth login") + ) { + return "unauthenticated"; + } + if (lower.includes("rate limit")) { + return "rate-limited"; + } + return "failed"; +} + +function listFieldsFor(state: PullRequestListState): string { + return state === "open" + ? [...GITHUB_PULL_REQUEST_LIST_FIELDS, GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD].join(",") + : GITHUB_PULL_REQUEST_LIST_FIELDS.join(","); +} + +function toEntry(input: { + readonly project: PullRequestProject; + readonly row: GitHubPullRequestListRow; + readonly viewer: string | null; +}): PullRequestListEntry { + const { project, row, viewer } = input; + const viewerLogin = viewer?.trim().toLowerCase() ?? ""; + const matchesViewer = (login: string) => + viewerLogin.length > 0 && login.toLowerCase() === viewerLogin; + + return { + provider: "github", + projectId: project.projectId, + projectTitle: project.title, + repository: project.repository, + number: row.number, + title: row.title, + url: row.url, + author: row.author, + headBranch: row.headBranch, + baseBranch: row.baseBranch, + state: row.state, + isDraft: row.isDraft, + additions: row.additions, + deletions: row.deletions, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + viewerIsAuthor: row.author !== null && matchesViewer(row.author.login), + viewerReviewRequested: row.reviewRequestedLogins.some(matchesViewer), + ...(row.reviewDecision === undefined ? {} : { reviewDecision: row.reviewDecision }), + ...(row.checksState === undefined ? {} : { checksState: row.checksState }), + labels: row.labels, + }; +} + +export const make = Effect.fn("makePullRequestService")(function* () { + const github = yield* GitHubCli.GitHubCli; + const projections = yield* ProjectionSnapshotQuery; + + const readProjects = (projectId: ProjectId | undefined) => + projections.getShellSnapshot().pipe( + Effect.mapError( + (error) => new PullRequestServiceError({ operation: "list", detail: error.message }), + ), + Effect.map((snapshot) => + snapshot.projects.flatMap((project) => { + if (projectId !== undefined && project.id !== projectId) { + return []; + } + const target = toPullRequestProject(project); + return target === null ? [] : [target]; + }), + ), + ); + + /** + * `gh auth status` is the only place the signed-in login is available, and it + * needs a working directory like every other `gh` call, so the cache is keyed + * by the one it ran in. A host we cannot read leaves the viewer unknown + * rather than failing the listing. + */ + const viewerCache = yield* Cache.make({ + capacity: VIEWER_CACHE_CAPACITY, + timeToLive: VIEWER_CACHE_TTL, + lookup: (cwd: string) => + github.execute({ cwd, args: ["auth", "status", "--json", "hosts"] }).pipe( + Effect.map((output): string | null => { + const status = parseGitHubAuthStatus(output.stdout); + const account = findAuthenticatedGitHubAccount( + status.accounts.filter((entry) => entry.host === GITHUB_HOST), + ); + return account?.account ?? null; + }), + Effect.catch(() => Effect.succeed(null)), + ), + }); + + const readProjectRows = (project: PullRequestProject, state: PullRequestListState) => + github + .execute({ + cwd: project.workspaceRoot, + args: [ + "pr", + "list", + "--repo", + project.repository, + "--state", + state, + "--limit", + String(state === "open" ? OPEN_LIST_LIMIT : SETTLED_LIST_LIMIT), + "--json", + listFieldsFor(state), + ], + }) + .pipe( + Effect.flatMap((output) => { + const raw = output.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed>([]); + } + + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubCli.GitHubCliError({ + operation: "pullRequests.list", + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubPullRequestListDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + }), + ); + + /** A project that fails becomes one error entry; the other projects still return. */ + const readProject = (input: { + readonly project: PullRequestProject; + readonly state: PullRequestListState; + readonly viewer: string | null; + }) => + readProjectRows(input.project, input.state).pipe( + Effect.map((rows): PullRequestProjectRead => ({ + entries: rows.map((row) => toEntry({ project: input.project, row, viewer: input.viewer })), + error: null, + })), + Effect.catch((error) => + Effect.succeed({ + entries: [], + error: { + projectId: input.project.projectId, + projectTitle: input.project.title, + repository: input.project.repository, + reason: classifyPullRequestListFailure(error.detail), + detail: error.detail, + }, + }), + ), + ); + + const loadList = Effect.fn("PullRequestService.load")(function* (key: PullRequestListCacheKey) { + const projects = dedupeProjectsByRepository(yield* readProjects(key.projectId)); + const first = projects[0]; + if (first === undefined) { + return { viewer: null, entries: [], errors: [] } satisfies PullRequestListResult; + } + + const viewer = yield* Cache.get(viewerCache, first.workspaceRoot); + const results = yield* Effect.forEach( + projects, + (project) => readProject({ project, state: key.state, viewer }), + { concurrency: PROJECT_CONCURRENCY }, + ); + + return { + viewer, + entries: results.flatMap((result) => result.entries), + errors: results.flatMap((result) => (result.error === null ? [] : [result.error])), + } satisfies PullRequestListResult; + }); + + const listCache = yield* Cache.make({ + capacity: LIST_CACHE_CAPACITY, + timeToLive: LIST_CACHE_TTL, + lookup: (key: string) => loadList(parseListCacheKey(key)), + }); + + return PullRequestService.of({ + list: (input) => + Effect.suspend(() => { + const key = listCacheKey({ + state: input.state, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + }); + return (input.force === true ? Cache.invalidate(listCache, key) : Effect.void).pipe( + Effect.andThen(Cache.get(listCache, key)), + ); + }), + }); +}); + +export const layer = Layer.effect(PullRequestService, make()); diff --git a/apps/server/src/pullRequest/gitHubPullRequestList.test.ts b/apps/server/src/pullRequest/gitHubPullRequestList.test.ts new file mode 100644 index 000000000..f62a3dca7 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestList.test.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequestList.ts"; + +const baseRow = { + number: 1, + title: "Add the pull requests page", + url: "https://github.com/octocat/example-app/pull/1", + author: { login: "octocat", is_bot: false }, + headRefName: "feature/pull-requests", + baseRefName: "main", + state: "OPEN", + mergedAt: null, + isDraft: false, + additions: 12, + deletions: 3, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-31T10:00:00Z", + reviewDecision: "", + reviewRequests: [], + labels: [], +}; + +function decodeRows(rows: ReadonlyArray) { + const result = decodeGitHubPullRequestListJson(JSON.stringify(rows)); + assert.equal(Result.isSuccess(result), true); + return Result.isSuccess(result) ? result.success : []; +} + +describe("decodeGitHubPullRequestListJson", () => { + it("resolves the pull request state from state and mergedAt", () => { + const rows = decodeRows([ + { ...baseRow, number: 1, state: "OPEN", mergedAt: null }, + { ...baseRow, number: 2, state: "OPEN", mergedAt: "2026-08-31T09:00:00Z" }, + { ...baseRow, number: 3, state: "MERGED", mergedAt: null }, + { ...baseRow, number: 4, state: "CLOSED", mergedAt: null }, + ]); + + assert.deepStrictEqual( + rows.map((row) => [row.number, row.state]), + [ + [1, "open"], + [2, "merged"], + [3, "merged"], + [4, "closed"], + ], + ); + }); + + it("collapses the status check rollup into one word", () => { + const rows = decodeRows([ + { + ...baseRow, + number: 1, + statusCheckRollup: [ + { status: "COMPLETED", conclusion: "SUCCESS" }, + { status: "COMPLETED", conclusion: "FAILURE" }, + { status: "IN_PROGRESS", conclusion: null }, + ], + }, + { + ...baseRow, + number: 2, + statusCheckRollup: [ + { status: "COMPLETED", conclusion: "SUCCESS" }, + { status: "IN_PROGRESS", conclusion: null }, + ], + }, + { + ...baseRow, + number: 3, + statusCheckRollup: [ + { status: "COMPLETED", conclusion: "SUCCESS" }, + { status: "COMPLETED", conclusion: "SKIPPED" }, + { state: "SUCCESS" }, + ], + }, + { ...baseRow, number: 4, statusCheckRollup: [] }, + { ...baseRow, number: 5 }, + ]); + + assert.deepStrictEqual( + rows.map((row) => [row.number, row.checksState]), + [ + [1, "failure"], + [2, "pending"], + [3, "success"], + [4, undefined], + [5, undefined], + ], + ); + }); + + it("skips a malformed row and keeps the rest", () => { + const rows = decodeRows([ + { ...baseRow, number: 0 }, + { ...baseRow, number: 7, title: " " }, + { ...baseRow, number: 8 }, + ]); + + assert.deepStrictEqual( + rows.map((row) => row.number), + [8], + ); + }); + + it("fails only when the payload itself cannot be read", () => { + assert.equal(Result.isFailure(decodeGitHubPullRequestListJson("not json at all")), true); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestList.ts b/apps/server/src/pullRequest/gitHubPullRequestList.ts new file mode 100644 index 000000000..b86e5b059 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestList.ts @@ -0,0 +1,243 @@ +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 { + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestChecksState, + type PullRequestReviewDecision, + type PullRequestState, +} from "@threadlines/contracts"; +import { decodeJsonResult, formatSchemaError } from "@threadlines/shared/schemaJson"; + +/** + * The JSON fields the pull requests page asks `gh pr list` for. + * + * `statusCheckRollup` is expensive on large repositories, so it is only worth + * paying for on the open listing, where the page renders a checks state. + */ +export const GITHUB_PULL_REQUEST_LIST_FIELDS = [ + "number", + "title", + "url", + "author", + "headRefName", + "baseRefName", + "state", + "isDraft", + "additions", + "deletions", + "createdAt", + "updatedAt", + "mergedAt", + "reviewDecision", + "reviewRequests", + "labels", +] as const; + +export const GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD = "statusCheckRollup"; + +/** One decoded `gh pr list` row. Project and viewer context is added by the caller. */ +export interface GitHubPullRequestListRow { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: { readonly login: string; readonly isBot: boolean } | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + /** User logins with a pending review request; team requests are dropped. */ + readonly reviewRequestedLogins: ReadonlyArray; + readonly reviewDecision?: PullRequestReviewDecision; + readonly checksState?: PullRequestChecksState; + readonly labels: ReadonlyArray<{ readonly name: string; readonly color: string | null }>; +} + +const GitHubAuthorSchema = Schema.Struct({ + login: Schema.String, + is_bot: Schema.optional(Schema.NullOr(Schema.Boolean)), + isBot: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const GitHubLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubReviewRequestSchema = Schema.Struct({ + __typename: Schema.optional(Schema.NullOr(Schema.String)), + login: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubStatusCheckSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubPullRequestListRowSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + headRefName: TrimmedNonEmptyString, + baseRefName: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + additions: Schema.optional(Schema.NullOr(NonNegativeInt)), + deletions: Schema.optional(Schema.NullOr(NonNegativeInt)), + createdAt: TrimmedNonEmptyString, + updatedAt: TrimmedNonEmptyString, + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.NullOr(Schema.Array(GitHubReviewRequestSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(GitHubLabelSchema))), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(GitHubStatusCheckSchema))), +}); + +const FAILING_CHECK_CONCLUSIONS = new Set([ + "FAILURE", + "ERROR", + "TIMED_OUT", + "CANCELLED", + "ACTION_REQUIRED", + "STARTUP_FAILURE", +]); +const PASSING_CHECK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]); + +function nonEmpty(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + const state = raw.state?.trim().toUpperCase(); + if (nonEmpty(raw.mergedAt) !== null || state === "MERGED") { + return "merged"; + } + return state === "CLOSED" ? "closed" : "open"; +} + +function normalizeReviewDecision( + value: string | null | undefined, +): PullRequestReviewDecision | undefined { + switch (value?.trim().toUpperCase()) { + case "APPROVED": + return "approved"; + case "CHANGES_REQUESTED": + return "changes-requested"; + case "REVIEW_REQUIRED": + return "review-required"; + default: + return undefined; + } +} + +/** + * Collapses `gh`'s per-check rollup into the one word the row renders. A check + * that has not completed outranks the passing checks around it, and any hard + * failure outranks everything. + */ +function normalizeChecksState( + checks: ReadonlyArray> | null | undefined, +): PullRequestChecksState | undefined { + if (!checks || checks.length === 0) { + return undefined; + } + + let pending = false; + for (const check of checks) { + const conclusion = (nonEmpty(check.conclusion) ?? nonEmpty(check.state) ?? "").toUpperCase(); + if (FAILING_CHECK_CONCLUSIONS.has(conclusion)) { + return "failure"; + } + + const status = (nonEmpty(check.status) ?? "").toUpperCase(); + const completed = + status.length > 0 ? status === "COMPLETED" : PASSING_CHECK_CONCLUSIONS.has(conclusion); + if (!completed) { + pending = true; + } + } + + return pending ? "pending" : "success"; +} + +function normalizeRow( + raw: Schema.Schema.Type, +): GitHubPullRequestListRow { + const authorLogin = nonEmpty(raw.author?.login); + const reviewDecision = normalizeReviewDecision(raw.reviewDecision); + const checksState = normalizeChecksState(raw.statusCheckRollup); + + return { + number: raw.number, + title: raw.title, + url: raw.url, + author: + authorLogin === null + ? null + : { login: authorLogin, isBot: raw.author?.is_bot === true || raw.author?.isBot === true }, + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: normalizeState(raw), + isDraft: raw.isDraft === true, + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestedLogins: (raw.reviewRequests ?? []).flatMap((request) => { + const typename = nonEmpty(request.__typename); + if (typename !== null && typename !== "User") { + return []; + } + const login = nonEmpty(request.login); + return login === null ? [] : [login]; + }), + ...(reviewDecision === undefined ? {} : { reviewDecision }), + ...(checksState === undefined ? {} : { checksState }), + labels: (raw.labels ?? []).flatMap((label) => { + const name = nonEmpty(label.name); + return name === null ? [] : [{ name, color: nonEmpty(label.color) }]; + }), + }; +} + +const decodePayload = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeRow = Schema.decodeUnknownExit(GitHubPullRequestListRowSchema); + +export const formatGitHubPullRequestListDecodeError = formatSchemaError; + +/** + * Decodes `gh pr list --json` output. A row `gh` reports in a shape we cannot + * use is dropped so one odd pull request never hides the rest; only a payload + * that is not a JSON array fails. + */ +export function decodeGitHubPullRequestListJson( + raw: string, +): Result.Result, Cause.Cause> { + const payload = decodePayload(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + + const rows: GitHubPullRequestListRow[] = []; + for (const entry of payload.success) { + const decoded = decodeRow(entry); + if (Exit.isFailure(decoded)) { + continue; + } + rows.push(normalizeRow(decoded.value)); + } + return Result.succeed(rows); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bd580edbf..15d34a923 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -129,6 +129,7 @@ import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitAuthRemediationService from "./git/GitAuthRemediationService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import { PullRequestService } from "./pullRequest/PullRequestService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -773,9 +774,12 @@ const buildAppUnderTest = (options?: { Layer.provide(gitAuthRemediationLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( - Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ - ...options?.layers?.sourceControlRepositoryService, - }), + Layer.mergeAll( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + ...options?.layers?.sourceControlRepositoryService, + }), + Layer.mock(PullRequestService)({}), + ), ), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 591021149..76923aa7c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -69,6 +69,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import { AutomaticGitFetchSupervisorLive } from "./vcs/AutomaticGitFetchSupervisor.ts"; import * as GitAuthRemediationService from "./git/GitAuthRemediationService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; @@ -246,6 +247,11 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l Layer.provideMerge(SourceControlProviderRegistryLayerLive), ); +// Reads pull requests straight through `gh`, so it needs the CLI plus the +// project list; `ProjectionSnapshotQuery` comes from the orchestration layer +// merged in below it. +const PullRequestServiceLayerLive = PullRequestService.layer.pipe(Layer.provide(GitHubCli.layer)); + const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsProjectConfig.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -303,7 +309,9 @@ const ProviderRuntimeLayerLive = Layer.mergeAll( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLayerLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), // Cross-provider handoff: builds a provider-agnostic context seed from the diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 500366f58..5956361c0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,6 +78,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import { PreviewAutomationBroker } from "./preview/PreviewAutomationBroker.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; +import { PullRequestService } from "./pullRequest/PullRequestService.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import { ProviderService } from "./provider/Services/ProviderService.ts"; import { readCodexInlineVisualization } from "./provider/CodexInlineVisualization.ts"; @@ -248,6 +249,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService; const gitAuthRemediation = yield* GitAuthRemediationService; + const pullRequests = yield* PullRequestService; const vcsProvisioning = yield* VcsProvisioningService; const previewAutomationBroker = yield* PreviewAutomationBroker; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -1956,6 +1958,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => gitAuthRemediation.apply(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), + [WS_METHODS.pullRequestsList]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { + "rpc.aggregate": "pullRequests", + }), [WS_METHODS.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 845a53d43..c86380f83 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -266,6 +266,7 @@ function createMockEnvironmentApi(input: { sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], + pullRequests: {} as EnvironmentApi["pullRequests"], realtime: {} as EnvironmentApi["realtime"], orchestration: { dispatchCommand: input.dispatchCommand, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3125915e0..961a99b0d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -26,6 +26,7 @@ import { FolderIcon, FolderPlusIcon, GaugeIcon, + GitPullRequestIcon, HomeIcon, LinkIcon, MessageSquareIcon, @@ -85,6 +86,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { waitForProjectInStore } from "../lib/waitForProject"; import { getLatestThreadForProject } from "../lib/threadSort"; import { threadSearchQueryOptions, type ThreadSearchTarget } from "../lib/threadSearchReactQuery"; +import { usePullRequestEnvironments } from "../lib/pullRequestsReactQuery"; import { cn, isMacPlatform, @@ -504,6 +506,7 @@ function OpenCommandPaletteDialog() { const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + const pullRequestEnvironments = usePullRequestEnvironments(); const addProjectEnvironmentOptions = useMemo(() => { const options: AddProjectEnvironmentOption[] = []; @@ -1598,6 +1601,21 @@ function OpenCommandPaletteDialog() { }); } + // Registered only where the sidebar row is: an action that lands on + // "unsupported" is worse than one the palette never offers. + if (pullRequestEnvironments.length > 0) { + actionItems.push({ + kind: "action", + value: "action:pull-requests", + searchTerms: ["pull requests", "prs", "pr", "reviews", "github"], + title: "Open pull requests", + icon: , + run: async () => { + await navigate({ to: "/pull-requests", search: { state: "open" } }); + }, + }); + } + actionItems.push({ kind: "action", value: "action:usage", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d9fbe4080..c4e977390 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,6 +121,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions"; +import { SidebarPullRequestsRow } from "./sidebar/SidebarPullRequestsRow"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { SidebarUsageMeter } from "./sidebar/SidebarUsageMeter"; import { SidebarVersionTag } from "./sidebar/SidebarVersionTag"; @@ -1549,43 +1550,50 @@ export default function Sidebar() { className="pointer-events-none absolute inset-x-0 top-0 z-20 h-3 bg-linear-to-b from-sidebar to-transparent" /> -
- - {/* A sibling, not a child: a button inside a button is invalid, - and starting a chat should not first walk you to the page. */} - - - } + {/* General Chats and Pull Requests read as a pair, so the gap + belongs under the pair rather than between the two rows, + and it survives an environment that has no pull requests. */} +
+
+ + {/* A sibling, not a child: a button inside a button is invalid, + and starting a chat should not first walk you to the page. */} + + + } + > + + + New general chat + +
+ +
({ + getGitStatusSnapshot: () => ({ data: null, error: null, cause: null, isPending: false }), + GIT_STATUS_STALE_MESSAGE: "Source control status isn't updating.", + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + rebuildGitStatusSubscription: () => undefined, + refreshGitStatus: async () => null, + refreshLocalGitStatus: async () => null, + resetGitStatusStateForTests: () => undefined, +})); + +const ENVIRONMENT_ID = EnvironmentId.make("pull-requests-browser-test"); +const PROJECT_ID = ProjectId.make("project-threadlines"); +const CWD = "/repo/project"; + +const DESCRIPTOR: ExecutionEnvironmentDescriptor = { + environmentId: ENVIRONMENT_ID, + label: "This device", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true, pullRequests: true }, +}; + +function makeEntry(overrides: Partial = {}): PullRequestListEntry { + return { + provider: "github", + projectId: PROJECT_ID, + projectTitle: "Threadlines", + repository: "threadlines/threadlines", + number: 1, + title: "Add the pull requests page", + url: "https://github.com/threadlines/threadlines/pull/1", + author: { login: "ada", isBot: false }, + headBranch: "feature/pull-requests", + baseBranch: "main", + state: "open", + isDraft: false, + additions: 12, + deletions: 3, + createdAt: "2026-09-01T10:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + viewerIsAuthor: false, + viewerReviewRequested: false, + labels: [], + ...overrides, + }; +} + +function makeEnvironmentApi(result: PullRequestListResult): EnvironmentApi { + return { + pullRequests: { + list: vi.fn(async () => result), + }, + git: { + resolvePullRequest: vi.fn(async () => ({ + pullRequest: { + number: 1, + title: "Add the pull requests page", + url: "https://github.com/threadlines/threadlines/pull/1", + headBranch: "feature/pull-requests", + baseBranch: "main", + state: "open" as const, + }, + })), + }, + } as unknown as EnvironmentApi; +} + +/** One workspace project so a row can find the checkout it belongs to. */ +function seedProject(): void { + useStore.setState({ + activeEnvironmentId: ENVIRONMENT_ID, + environmentStateById: { + [ENVIRONMENT_ID]: { + projectIds: [PROJECT_ID], + projectById: { + [PROJECT_ID]: { + id: PROJECT_ID, + environmentId: ENVIRONMENT_ID, + kind: "workspace", + name: "Threadlines", + cwd: CWD, + }, + }, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }, + }, + } as never); +} + +function createTestRouter(children: ReactNode) { + const rootRoute = createRootRoute({ component: () => children }); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); +} + +async function renderPage(result: PullRequestListResult) { + __setEnvironmentApiOverrideForTests(ENVIRONMENT_ID, makeEnvironmentApi(result)); + useSavedEnvironmentRuntimeStore.getState().patch(ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: DESCRIPTOR, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const host = document.createElement("div"); + host.style.width = "1000px"; + host.style.height = "900px"; + document.body.append(host); + + const router = createTestRouter( + + + undefined} /> + + , + ); + const screen = await render(, { container: host }); + + return { + async cleanup() { + await screen.unmount(); + queryClient.clear(); + host.remove(); + }, + }; +} + +describe("PullRequestsView", () => { + beforeEach(() => { + resetAppAtomRegistryForTests(); + resetSavedEnvironmentRuntimeStoreForTests(); + seedProject(); + }); + + afterEach(() => { + __resetEnvironmentApiOverridesForTests(); + resetSavedEnvironmentRuntimeStoreForTests(); + useStore.setState({ activeEnvironmentId: null, environmentStateById: {} } as never); + }); + + it("groups the open list by what needs the viewer", async () => { + const rendered = await renderPage({ + viewer: "ada", + entries: [ + makeEntry({ number: 1, title: "Needs a review", viewerReviewRequested: true }), + makeEntry({ number: 2, title: "Mine and quiet", viewerIsAuthor: true }), + makeEntry({ number: 3, title: "Mine and approved", viewerIsAuthor: true }), + makeEntry({ number: 4, title: "Someone else's work" }), + ], + errors: [], + }); + + await expect.element(page.getByText("Needs you · 1")).toBeVisible(); + await expect.element(page.getByText("Yours · 2")).toBeVisible(); + await expect.element(page.getByText("Others · 1")).toBeVisible(); + + await rendered.cleanup(); + }); + + it("asks the user to sign in when every project is unauthenticated", async () => { + const rendered = await renderPage({ + viewer: null, + entries: [], + errors: [ + { + projectId: PROJECT_ID, + projectTitle: "Threadlines", + repository: "threadlines/threadlines", + reason: "unauthenticated", + detail: "gh: not logged into any GitHub hosts", + }, + ], + }); + + await expect.element(page.getByText("Sign in to GitHub CLI")).toBeVisible(); + await expect + .element(page.getByRole("button", { name: "Open Source Control settings" })) + .toBeVisible(); + + await rendered.cleanup(); + }); + + it("opens the checkout dialog prefilled with the pull request URL", async () => { + const rendered = await renderPage({ + viewer: "ada", + entries: [ + makeEntry({ number: 42, url: "https://github.com/threadlines/threadlines/pull/42" }), + ], + errors: [], + }); + + await expect.element(page.getByTestId("pull-requests-row")).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Review in a thread" })); + + const referenceInput = page.getByPlaceholder(/URL, checkout command/); + await expect.element(referenceInput).toBeVisible(); + await expect + .element(referenceInput) + .toHaveValue("https://github.com/threadlines/threadlines/pull/42"); + + await rendered.cleanup(); + }); +}); diff --git a/apps/web/src/components/pull-requests/PullRequestsView.tsx b/apps/web/src/components/pull-requests/PullRequestsView.tsx new file mode 100644 index 000000000..66cb596da --- /dev/null +++ b/apps/web/src/components/pull-requests/PullRequestsView.tsx @@ -0,0 +1,632 @@ +import { scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; +import type { + EnvironmentId, + ProjectId, + PullRequestListState, + ThreadId, +} from "@threadlines/contracts"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + ExternalLinkIcon, + GitBranchPlusIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + MessagesSquareIcon, + RefreshCwIcon, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { openExternalUrl } from "../../lib/externalLinks"; +import { + PULL_REQUEST_PAGE_REFETCH_INTERVAL_MS, + refreshPullRequestList, + usePullRequestLists, + type PullRequestEnvironmentFailure, +} from "../../lib/pullRequestsReactQuery"; +import { cn, newThreadId } from "../../lib/utils"; +import { + selectSidebarThreadsAcrossEnvironments, + selectWorkspaceProjectsAcrossEnvironments, + useStore, +} from "../../store"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import type { SidebarThreadSummary } from "../../types"; +import { DesktopPageTitlebar } from "../DesktopPageTitlebar"; +import { PullRequestThreadDialog } from "../PullRequestThreadDialog"; +import { DiffStatLabel } from "../chat/DiffStatLabel"; +import { Button } from "../ui/button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { Input } from "../ui/input"; +import { PageTabButton } from "../ui/page-tabs"; +import { Skeleton } from "../ui/skeleton"; +import { TooltipWrapper } from "../ui/tooltip"; +import { + groupPullRequests, + hasGitHubProject, + linkThreadsToPullRequests, + matchesPullRequestQuery, + pullRequestEntryKey, + requiresGitHubSignIn, + resolveNeedsYouReason, + resolvePullRequestListSpan, + type PullRequestEntry, + type PullRequestProjectFailure, +} from "./pullRequests.logic"; + +const STATE_TABS = [ + { value: "open", label: "Open" }, + { value: "merged", label: "Merged" }, + { value: "closed", label: "Closed" }, +] as const satisfies readonly { value: PullRequestListState; label: string }[]; + +const LIST_PANEL_ID = "pull-requests-list"; + +const EMPTY_LIST_COPY: Record = { + open: "No open pull requests.", + merged: "Nothing merged recently.", + closed: "Nothing closed recently.", +}; + +const GROUP_LABEL_CLASS = + "mb-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55 select-none"; + +const META_SEPARATOR_CLASS = "shrink-0 text-muted-foreground/30"; + +/** + * Hidden until the row is hovered or holds focus; touch has no hover. Spans the + * row's full height on the hovered fill and fades in from the left, so the + * time and diff stat underneath disappear instead of peeking out around the + * buttons. + */ +const ROW_ACTIONS_CLASS = + // `--muted` is translucent, so the hovered fill is rebuilt here on an opaque + // page background rather than stacked on top of the row's own tint. + "absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-r-md bg-background pr-2 pl-8 opacity-0 transition-opacity before:pointer-events-none before:absolute before:inset-0 before:rounded-r-md before:bg-muted [mask-image:linear-gradient(to_right,transparent,black_24px)] group-hover/pr-row:opacity-100 group-focus-within/pr-row:opacity-100 pointer-coarse:opacity-100"; + +interface PullRequestGlyph { + readonly Icon: typeof GitPullRequestIcon; + readonly className: string; + readonly label: string; +} + +/** Colours copied from the thread status indicators so one state reads alike everywhere. */ +function resolveGlyph(entry: PullRequestEntry): PullRequestGlyph { + if (entry.state === "merged") { + return { + Icon: GitMergeIcon, + className: "text-violet-600 dark:text-violet-300/90", + label: "Merged", + }; + } + if (entry.state === "closed") { + return { + Icon: GitPullRequestClosedIcon, + className: "text-zinc-500 dark:text-zinc-400/80", + label: "Closed", + }; + } + if (entry.isDraft) { + return { Icon: GitPullRequestDraftIcon, className: "text-muted-foreground/60", label: "Draft" }; + } + return { + Icon: GitPullRequestIcon, + className: "text-emerald-600 dark:text-emerald-300/90", + label: "Open", + }; +} + +interface PullRequestThreadDialogTarget { + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly threadId: ThreadId; + readonly cwd: string | null; + readonly url: string; +} + +/** + * The pull requests destination: every GitHub project in the workspace, in one + * list, grouped by what the signed-in user still has to do about it. + */ +export function PullRequestsView({ + state, + onStateChange, +}: { + readonly state: PullRequestListState; + readonly onStateChange: (state: PullRequestListState) => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { handleNewThread } = useNewThreadHandler(); + const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const projects = useStore(useShallow(selectWorkspaceProjectsAcrossEnvironments)); + const [query, setQuery] = useState(""); + const [isRefreshing, setIsRefreshing] = useState(false); + const [dialogTarget, setDialogTarget] = useState(null); + + const snapshot = usePullRequestLists({ + state, + refetchIntervalMs: PULL_REQUEST_PAGE_REFETCH_INTERVAL_MS, + }); + + const visibleEntries = useMemo( + () => snapshot.entries.filter((entry) => matchesPullRequestQuery(entry, query)), + [query, snapshot.entries], + ); + const groups = useMemo( + () => groupPullRequests({ entries: visibleEntries, viewer: snapshot.viewer, state }), + [snapshot.viewer, state, visibleEntries], + ); + const threadsByEntryKey = useMemo( + () => linkThreadsToPullRequests(visibleEntries, threads, projects), + [projects, threads, visibleEntries], + ); + const span = useMemo(() => resolvePullRequestListSpan(visibleEntries), [visibleEntries]); + + const environments = snapshot.environments; + const handleRefresh = useCallback(() => { + setIsRefreshing(true); + void Promise.allSettled( + environments.map((environment) => + refreshPullRequestList(queryClient, { + environmentId: environment.environmentId, + state, + }), + ), + ).finally(() => { + setIsRefreshing(false); + }); + }, [environments, queryClient, state]); + + const handleOpenThread = useCallback( + (thread: SidebarThreadSummary) => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + [navigate], + ); + + const handleReviewInThread = useCallback( + (entry: PullRequestEntry) => { + const project = projects.find( + (candidate) => + candidate.environmentId === entry.environmentId && candidate.id === entry.projectId, + ); + setDialogTarget({ + key: pullRequestEntryKey(entry), + environmentId: entry.environmentId, + projectId: entry.projectId, + threadId: newThreadId(), + cwd: project?.cwd ?? null, + url: entry.url, + }); + }, + [projects], + ); + + const isSearching = query.trim().length > 0; + const showSignIn = requiresGitHubSignIn({ + entries: snapshot.entries, + failures: snapshot.failures, + }); + + const body = (() => { + if (snapshot.environments.length === 0) { + return ; + } + if (snapshot.isPending) { + return ; + } + if (showSignIn) { + return ( + + + Sign in to GitHub CLI + + Threadlines reads pull requests through gh on the server. + + + + + + + ); + } + if ( + snapshot.entries.length === 0 && + snapshot.failures.length === 0 && + !hasGitHubProject(projects) + ) { + return ( + + ); + } + if (visibleEntries.length === 0) { + return ( + + ); + } + + return groups.map((group) => ( +
+ {group.label ? ( +

+ {group.label} · {group.entries.length} +

+ ) : null} +
+ {group.entries.map((entry) => ( + + ))} +
+
+ )); + })(); + + return ( +
+ + {/* The pane-wide element scrolls so the scrollbar hugs the pane's edge; + the reading column centers inside it. Wider than a chat list because + each row carries a meta line as well as a title. */} +
+
+

Pull requests

+

+ Across every project with a GitHub remote. +

+ + {/* Nothing to filter, search or refresh when no server can answer at all. */} + {snapshot.environments.length > 0 ? ( + <> + {/* The same strip the settings pages use: text on a hairline, the + active tab underlined, the refresh control in the strip's + trailing slot. */} +
+
+ {STATE_TABS.map((tab) => ( + onStateChange(tab.value)} + /> + ))} +
+ +
+ setQuery(event.target.value)} + /> + + ) : null} + +
+
+ + {dialogTarget ? ( + { + if (!open) { + setDialogTarget(null); + } + }} + onPrepared={async ({ branch, worktreePath }) => { + await handleNewThread( + scopeProjectRef(dialogTarget.environmentId, dialogTarget.projectId), + { + branch, + worktreePath, + envMode: worktreePath ? "worktree" : "local", + }, + ); + }} + /> + ) : null} +
+ ); +} + +function PullRequestsEmpty({ description }: { readonly description: string }) { + return ( + + + {description} + + + ); +} + +const SKELETON_ROW_WIDTHS = ["w-64", "w-48", "w-72"] as const; + +function PullRequestsLoadingSkeleton() { + return ( +
+ {SKELETON_ROW_WIDTHS.map((width) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +/** + * One muted line for the projects and computers the listing could not read. + * Named counts up front, the details behind the line, and a way to try again. + */ +function PullRequestsNotice({ + failures, + environmentFailures, + hidden, + onRetry, +}: { + readonly failures: readonly PullRequestProjectFailure[]; + readonly environmentFailures: readonly PullRequestEnvironmentFailure[]; + readonly hidden: boolean; + readonly onRetry: () => void; +}) { + if (hidden || (failures.length === 0 && environmentFailures.length === 0)) { + return null; + } + + const summary = [ + failures.length > 0 + ? `Couldn't load ${failures.length} project${failures.length === 1 ? "" : "s"}` + : null, + environmentFailures.length > 0 + ? `Couldn't reach ${environmentFailures.length} computer${environmentFailures.length === 1 ? "" : "s"}` + : null, + ] + .filter((part) => part !== null) + .join(", "); + + return ( +
+ + {failures.map((failure) => ( +

+ {failure.projectTitle}: {failure.detail} +

+ ))} + {environmentFailures.map((failure) => ( +

+ {failure.label}: {failure.message} +

+ ))} +
+ } + > + + {summary} + + + +
+ ); +} + +function PullRequestRow({ + entry, + linkedThread, + showRepository, + showEnvironment, + onOpenThread, + onReviewInThread, +}: { + readonly entry: PullRequestEntry; + readonly linkedThread: SidebarThreadSummary | null; + readonly showRepository: boolean; + readonly showEnvironment: boolean; + readonly onOpenThread: (thread: SidebarThreadSummary) => void; + readonly onReviewInThread: (entry: PullRequestEntry) => void; +}) { + const { Icon: GlyphIcon, className: glyphClassName, label: glyphLabel } = resolveGlyph(entry); + const reason = resolveNeedsYouReason(entry); + const visibleLabels = entry.labels.slice(0, 2); + const hiddenLabelCount = entry.labels.length - visibleLabels.length; + // A fixed, ordered set of optional slots, so the absent ones simply drop out + // and the separators still land between what is left. + const meta: readonly { key: string; className: string; text: string }[] = [ + { key: "number", className: "shrink-0 font-mono", text: `#${entry.number}` }, + ...(showRepository + ? [{ key: "repository", className: "truncate", text: entry.repository }] + : []), + ...(entry.author ? [{ key: "author", className: "truncate", text: entry.author.login }] : []), + ...(showEnvironment + ? [{ key: "environment", className: "truncate", text: entry.environmentLabel }] + : []), + ...(reason + ? [ + { + key: "reason", + className: cn( + "shrink-0", + reason === "Approved" + ? "text-emerald-600 dark:text-emerald-300/90" + : "text-amber-600/90 dark:text-amber-400/80", + ), + text: reason, + }, + ] + : []), + ]; + + return ( + // The fill answers to the wrapper, not to the button: the hover actions are + // siblings (a button inside a button is invalid), and reaching for one must + // not drop the row's highlight out from under the cursor. +
+ + + + + +
+ ); +} diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.test.ts b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts new file mode 100644 index 000000000..cb421179b --- /dev/null +++ b/apps/web/src/components/pull-requests/pullRequests.logic.test.ts @@ -0,0 +1,323 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type PullRequestListEntry, +} from "@threadlines/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { Project, SidebarThreadSummary } from "../../types"; +import { + countNeedsYou, + groupPullRequests, + linkThreadsToPullRequests, + matchesPullRequestQuery, + pullRequestEntryKey, + resolveNeedsYouReason, + type PullRequestEntry, +} from "./pullRequests.logic"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); +const PROJECT_ID = ProjectId.make("project-threadlines"); +const OTHER_PROJECT_ID = ProjectId.make("project-other"); +const WORKTREE_PROJECT_ID = ProjectId.make("project-threadlines-worktree"); + +/** A workspace project whose remote resolved to `repository`, or to nothing. */ +function project(input: { + readonly id: ProjectId; + readonly repository: string | null; + readonly environmentId?: EnvironmentId; +}): Project { + const [owner = "", name = ""] = (input.repository ?? "/").split("/"); + return { + id: input.id, + environmentId: input.environmentId ?? ENVIRONMENT_ID, + kind: "workspace", + name: input.id, + cwd: `/workspaces/${input.id}`, + repositoryIdentity: + input.repository === null + ? null + : { + canonicalKey: `github:${input.repository}`, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://github.com/${input.repository}.git`, + }, + provider: "github", + owner, + name, + }, + defaultModelSelection: null, + scripts: [], + }; +} + +const PROJECTS = [ + project({ id: PROJECT_ID, repository: "threadlines/threadlines" }), + // A worktree checkout of the same remote, spelled the way GitHub also accepts. + project({ id: WORKTREE_PROJECT_ID, repository: "Threadlines/Threadlines" }), + project({ id: OTHER_PROJECT_ID, repository: "other/repo" }), + project({ + id: OTHER_PROJECT_ID, + repository: "threadlines/threadlines", + environmentId: OTHER_ENVIRONMENT_ID, + }), +]; + +function entry(overrides: Partial = {}): PullRequestEntry { + const base: PullRequestListEntry = { + provider: "github", + projectId: PROJECT_ID, + projectTitle: "Threadlines", + repository: "threadlines/threadlines", + number: 1, + title: "Add the pull requests page", + url: "https://github.com/threadlines/threadlines/pull/1", + author: { login: "ada", isBot: false }, + headBranch: "feature/pull-requests", + baseBranch: "main", + state: "open", + isDraft: false, + additions: 10, + deletions: 2, + createdAt: "2026-09-01T10:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + viewerIsAuthor: false, + viewerReviewRequested: false, + labels: [], + }; + return { + ...base, + environmentId: ENVIRONMENT_ID, + environmentLabel: "This device", + ...overrides, + }; +} + +function thread(overrides: Partial = {}): SidebarThreadSummary { + return { + id: ThreadId.make("thread-1"), + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + title: "Build the page", + interactionMode: "default", + session: null, + createdAt: "2026-09-01T09:00:00.000Z", + archivedAt: null, + pinnedAt: null, + doneOverride: null, + lastSeenAt: null, + updatedAt: "2026-09-01T11:00:00.000Z", + latestTurn: null, + branch: "feature/pull-requests", + worktreePath: null, + effectiveCwd: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + cumulativeDiffStat: null, + ...overrides, + }; +} + +describe("resolveNeedsYouReason", () => { + it("puts a review request ahead of every author signal", () => { + expect( + resolveNeedsYouReason( + entry({ + viewerReviewRequested: true, + viewerIsAuthor: true, + reviewDecision: "changes-requested", + checksState: "failure", + }), + ), + ).toBe("Review requested"); + }); + + it("reports changes before failing checks for the author's own row", () => { + expect( + resolveNeedsYouReason( + entry({ + viewerIsAuthor: true, + reviewDecision: "changes-requested", + checksState: "failure", + }), + ), + ).toBe("Changes requested"); + }); + + it("stays quiet for a draft the viewer already had approved", () => { + expect( + resolveNeedsYouReason( + entry({ viewerIsAuthor: true, reviewDecision: "approved", isDraft: true }), + ), + ).toBeNull(); + expect(resolveNeedsYouReason(entry({ viewerIsAuthor: true, reviewDecision: "approved" }))).toBe( + "Approved", + ); + }); + + it("says nothing about a merged or closed row", () => { + expect( + resolveNeedsYouReason(entry({ state: "merged", viewerReviewRequested: true })), + ).toBeNull(); + }); + + it("ignores another author's failing checks", () => { + expect(resolveNeedsYouReason(entry({ checksState: "failure" }))).toBeNull(); + }); +}); + +describe("groupPullRequests", () => { + it("places each open row in exactly one group, newest first", () => { + const needsYou = entry({ number: 1, viewerReviewRequested: true }); + const yoursOlder = entry({ + number: 2, + viewerIsAuthor: true, + updatedAt: "2026-09-01T08:00:00.000Z", + }); + const yoursNewer = entry({ + number: 3, + viewerIsAuthor: true, + updatedAt: "2026-09-01T13:00:00.000Z", + }); + const others = entry({ number: 4 }); + + const groups = groupPullRequests({ + entries: [others, yoursOlder, needsYou, yoursNewer], + viewer: "ada", + state: "open", + }); + + expect(groups.map((group) => group.label)).toEqual(["Needs you", "Yours", "Others"]); + expect(groups[0]?.entries.map((row) => row.number)).toEqual([1]); + expect(groups[1]?.entries.map((row) => row.number)).toEqual([3, 2]); + expect(groups[2]?.entries.map((row) => row.number)).toEqual([4]); + }); + + it("drops empty groups instead of heading an absent list", () => { + const groups = groupPullRequests({ + entries: [entry({ number: 1 }), entry({ number: 2 })], + viewer: "ada", + state: "open", + }); + expect(groups.map((group) => group.label)).toEqual(["Others"]); + }); + + it("falls back to one unlabelled list without a viewer or outside the open tab", () => { + const withoutViewer = groupPullRequests({ + entries: [entry({ number: 1, viewerReviewRequested: true })], + viewer: null, + state: "open", + }); + expect(withoutViewer).toHaveLength(1); + expect(withoutViewer[0]?.label).toBeNull(); + + const merged = groupPullRequests({ + entries: [entry({ number: 1, state: "merged", viewerIsAuthor: true })], + viewer: "ada", + state: "merged", + }); + expect(merged).toHaveLength(1); + expect(merged[0]?.label).toBeNull(); + }); + + it("counts only the rows that need the viewer", () => { + expect( + countNeedsYou([ + entry({ number: 1, viewerReviewRequested: true }), + entry({ number: 2, viewerIsAuthor: true }), + entry({ number: 3 }), + ]), + ).toBe(1); + }); +}); + +describe("linkThreadsToPullRequests", () => { + it("links live threads on the same branch, most recently updated first", () => { + const row = entry(); + const older = thread({ + id: ThreadId.make("thread-old"), + updatedAt: "2026-09-01T09:00:00.000Z", + }); + const newer = thread({ + id: ThreadId.make("thread-new"), + updatedAt: "2026-09-01T14:00:00.000Z", + }); + + const linked = linkThreadsToPullRequests([row], [older, newer], PROJECTS); + + expect(linked.get(pullRequestEntryKey(row))?.map((match) => match.id)).toEqual([ + "thread-new", + "thread-old", + ]); + }); + + it("links a thread from a sibling project on the same repository", () => { + const row = entry(); + const linked = linkThreadsToPullRequests( + [row], + [thread({ id: ThreadId.make("worktree-thread"), projectId: WORKTREE_PROJECT_ID })], + PROJECTS, + ); + + expect(linked.get(pullRequestEntryKey(row))?.map((match) => match.id)).toEqual([ + "worktree-thread", + ]); + }); + + it("ignores archived threads, other branches, other repositories, and other environments", () => { + const row = entry(); + const linked = linkThreadsToPullRequests( + [row], + [ + thread({ id: ThreadId.make("archived"), archivedAt: "2026-09-01T10:00:00.000Z" }), + thread({ id: ThreadId.make("other-branch"), branch: "main" }), + thread({ id: ThreadId.make("no-branch"), branch: null }), + thread({ id: ThreadId.make("other-repository"), projectId: OTHER_PROJECT_ID }), + thread({ id: ThreadId.make("other-env"), environmentId: OTHER_ENVIRONMENT_ID }), + ], + PROJECTS, + ); + + expect(linked.has(pullRequestEntryKey(row))).toBe(false); + }); +}); + +describe("matchesPullRequestQuery", () => { + const row = entry({ + number: 412, + title: "Add the pull requests page", + author: { login: "ada", isBot: false }, + headBranch: "feature/pull-requests", + repository: "threadlines/threadlines", + labels: [{ name: "needs-design", color: "d73a4a" }], + }); + + it("matches the title, number, author, branch, repository, and labels", () => { + for (const query of [ + "PULL requests", + "#412", + "412", + "ada", + "feature/pull", + "threadlines/threadlines", + "needs-design", + ]) { + expect(matchesPullRequestQuery(row, query)).toBe(true); + } + }); + + it("ANDs the words, so more typing narrows", () => { + expect(matchesPullRequestQuery(row, "ada page")).toBe(true); + expect(matchesPullRequestQuery(row, "ada terminal")).toBe(false); + }); + + it("keeps everything for an empty query", () => { + expect(matchesPullRequestQuery(row, " ")).toBe(true); + }); +}); diff --git a/apps/web/src/components/pull-requests/pullRequests.logic.ts b/apps/web/src/components/pull-requests/pullRequests.logic.ts new file mode 100644 index 000000000..d83ba12ae --- /dev/null +++ b/apps/web/src/components/pull-requests/pullRequests.logic.ts @@ -0,0 +1,334 @@ +import type { + EnvironmentId, + PullRequestListEntry, + PullRequestListProjectError, + PullRequestListResult, + PullRequestListState, +} from "@threadlines/contracts"; + +import type { Project, SidebarThreadSummary } from "../../types"; + +/** + * A listing row plus the environment it came from. The wire type leaves the + * environment implicit because one call only ever covers one server, and the + * page merges several. + */ +export type PullRequestEntry = PullRequestListEntry & { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}; + +/** A project the host refused to list, scoped the same way as an entry. */ +export type PullRequestProjectFailure = PullRequestListProjectError & { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}; + +/** The reasons a row is put in front of the user, in priority order. */ +export type PullRequestNeedsYouReason = + | "Review requested" + | "Changes requested" + | "Checks failing" + | "Approved"; + +export type PullRequestGroupId = "needs-you" | "yours" | "others" | "all"; + +export interface PullRequestGroup { + readonly id: PullRequestGroupId; + /** Null when the list is one flat group, where a heading would say nothing. */ + readonly label: string | null; + readonly entries: readonly PullRequestEntry[]; +} + +export interface PullRequestsSearch { + readonly state: PullRequestListState; +} + +/** The route's `state` param. Anything unrecognised lands on the Open tab. */ +export function parsePullRequestsSearch(search: Record): PullRequestsSearch { + const state = search["state"]; + return { state: state === "merged" || state === "closed" ? state : "open" }; +} + +/** + * Identifies one row across environments and repositories. The project is + * deliberately left out: a checkout and its worktrees are separate projects on + * one remote, and the pull request is the same one from any of them. + */ +export function pullRequestEntryKey(entry: PullRequestEntry): string { + return `${entry.environmentId}:${repositoryKey(entry.repository)}:${entry.number}`; +} + +/** GitHub repository names are case-insensitive, so comparisons are too. */ +function repositoryKey(repository: string): string { + return repository.toLowerCase(); +} + +/** `owner/name` for a project that has a resolved GitHub remote, else null. */ +export function projectRepository(project: Project): string | null { + const identity = project.repositoryIdentity; + if (identity == null || identity.provider !== "github") { + return null; + } + const owner = identity.owner?.trim() ?? ""; + const name = identity.name?.trim() ?? ""; + return owner.length > 0 && name.length > 0 ? `${owner}/${name}` : null; +} + +function updatedAtMs(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function byUpdatedAtDesc(left: { updatedAt: string }, right: { updatedAt: string }): number { + return updatedAtMs(right.updatedAt) - updatedAtMs(left.updatedAt); +} + +function threadActivityMs(thread: SidebarThreadSummary): number { + return updatedAtMs(thread.updatedAt ?? thread.createdAt); +} + +/** + * One listing per environment, merged into the page's row list. + * + * The state filter is defensive: a host can answer a "closed" listing with a + * merged row, and a row under the wrong tab reads as a bug in the page. + */ +export function mergePullRequestListResults(input: { + readonly state: PullRequestListState; + readonly results: readonly { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly data: PullRequestListResult | undefined; + }[]; +}): { + readonly entries: readonly PullRequestEntry[]; + readonly failures: readonly PullRequestProjectFailure[]; + readonly viewer: string | null; +} { + const entries: PullRequestEntry[] = []; + const failures: PullRequestProjectFailure[] = []; + let viewer: string | null = null; + + for (const result of input.results) { + if (!result.data) continue; + viewer ??= result.data.viewer; + for (const entry of result.data.entries) { + if (entry.state !== input.state) continue; + entries.push({ + ...entry, + environmentId: result.environmentId, + environmentLabel: result.environmentLabel, + }); + } + for (const failure of result.data.errors) { + failures.push({ + ...failure, + environmentId: result.environmentId, + environmentLabel: result.environmentLabel, + }); + } + } + + return { entries, failures, viewer }; +} + +/** + * The threads working each pull request, keyed by {@link pullRequestEntryKey}. + * + * A thread counts when its project points at the pull request's repository on + * the same environment and it is checked out on the head branch. Matching by + * repository rather than project lets a thread in a worktree project claim the + * row its sibling checkout produced. Archived threads are past work, so they + * never claim a row. + */ +export function linkThreadsToPullRequests( + entries: readonly PullRequestEntry[], + threads: readonly SidebarThreadSummary[], + projects: readonly Project[], +): ReadonlyMap { + const linked = new Map(); + if (entries.length === 0) { + return linked; + } + + const repositoryByProject = new Map(); + for (const project of projects) { + const repository = projectRepository(project); + if (repository !== null) { + repositoryByProject.set(`${project.environmentId}:${project.id}`, repositoryKey(repository)); + } + } + const candidates = threads.flatMap((thread) => { + if (thread.archivedAt !== null || thread.branch === null) { + return []; + } + const repository = repositoryByProject.get(`${thread.environmentId}:${thread.projectId}`); + return repository === undefined ? [] : [{ thread, repository }]; + }); + for (const entry of entries) { + const entryRepository = repositoryKey(entry.repository); + const matches = candidates.flatMap((candidate) => + candidate.thread.environmentId === entry.environmentId && + candidate.repository === entryRepository && + candidate.thread.branch === entry.headBranch + ? [candidate.thread] + : [], + ); + if (matches.length === 0) continue; + linked.set( + pullRequestEntryKey(entry), + matches.toSorted((left, right) => threadActivityMs(right) - threadActivityMs(left)), + ); + } + + return linked; +} + +/** + * Why this row is waiting on the user, or null when it is only news. First + * match wins so a row states one thing rather than a list of conditions. + */ +export function resolveNeedsYouReason(entry: PullRequestEntry): PullRequestNeedsYouReason | null { + if (entry.state !== "open") { + return null; + } + if (entry.viewerReviewRequested) { + return "Review requested"; + } + if (!entry.viewerIsAuthor) { + return null; + } + if (entry.reviewDecision === "changes-requested") { + return "Changes requested"; + } + if (entry.checksState === "failure") { + return "Checks failing"; + } + if (entry.reviewDecision === "approved" && !entry.isDraft) { + return "Approved"; + } + return null; +} + +/** How many open rows are waiting on the user. Drives the sidebar count. */ +export function countNeedsYou(entries: readonly PullRequestEntry[]): number { + let count = 0; + for (const entry of entries) { + if (resolveNeedsYouReason(entry) !== null) { + count += 1; + } + } + return count; +} + +/** + * The open list answers "what needs me" first, then the user's own work, then + * everything else; a row belongs to exactly one group. Without a signed-in + * viewer none of that is knowable, so the list stays flat, as it does for the + * merged and closed tabs where the question does not apply. + */ +export function groupPullRequests(input: { + readonly entries: readonly PullRequestEntry[]; + readonly viewer: string | null; + readonly state: PullRequestListState; +}): readonly PullRequestGroup[] { + const sorted = input.entries.toSorted(byUpdatedAtDesc); + if (input.state !== "open" || input.viewer === null) { + return sorted.length === 0 ? [] : [{ id: "all", label: null, entries: sorted }]; + } + + const needsYou: PullRequestEntry[] = []; + const yours: PullRequestEntry[] = []; + const others: PullRequestEntry[] = []; + for (const entry of sorted) { + if (resolveNeedsYouReason(entry) !== null) { + needsYou.push(entry); + } else if (entry.viewerIsAuthor) { + yours.push(entry); + } else { + others.push(entry); + } + } + + return ( + [ + { id: "needs-you", label: "Needs you", entries: needsYou }, + { id: "yours", label: "Yours", entries: yours }, + { id: "others", label: "Others", entries: others }, + ] as const + ).filter((group) => group.entries.length > 0); +} + +/** + * Local search over what the row already shows plus what a user would type + * looking for it. Words are ANDed so typing more narrows. + */ +export function matchesPullRequestQuery(entry: PullRequestEntry, query: string): boolean { + const words = query + .toLowerCase() + .split(/\s+/u) + .filter((word) => word.length > 0); + if (words.length === 0) { + return true; + } + const haystack = entryHaystack(entry); + return words.every((word) => haystack.includes(word)); +} + +function entryHaystack(entry: PullRequestEntry): string { + return [ + entry.title, + `#${entry.number}`, + String(entry.number), + entry.author?.login ?? "", + entry.headBranch, + entry.repository, + ...entry.labels.map((label) => label.name), + ] + .join(" ") + .toLowerCase(); +} + +/** + * Which optional columns the second line earns. A repository or an environment + * name is only worth the space when the list actually spans more than one. + */ +export function resolvePullRequestListSpan(entries: readonly PullRequestEntry[]): { + readonly multipleRepositories: boolean; + readonly multipleEnvironments: boolean; +} { + const repositories = new Set(); + const environments = new Set(); + for (const entry of entries) { + repositories.add(entry.repository); + environments.add(entry.environmentId); + } + return { + multipleRepositories: repositories.size > 1, + multipleEnvironments: environments.size > 1, + }; +} + +/** Whether any workspace project could produce a GitHub listing at all. */ +export function hasGitHubProject(projects: readonly Project[]): boolean { + return projects.some((project) => projectRepository(project) !== null); +} + +/** + * True when nothing came back and every project failed for a reason the user + * fixes by signing the server's `gh` in, which is a different page from an + * empty list. + */ +export function requiresGitHubSignIn(input: { + readonly entries: readonly PullRequestEntry[]; + readonly failures: readonly PullRequestProjectFailure[]; +}): boolean { + return ( + input.entries.length === 0 && + input.failures.length > 0 && + input.failures.every( + (failure) => failure.reason === "missing-tool" || failure.reason === "unauthenticated", + ) + ); +} diff --git a/apps/web/src/components/settings/CompactVersionAdvisory.tsx b/apps/web/src/components/settings/CompactVersionAdvisory.tsx index 54993de88..894091d4d 100644 --- a/apps/web/src/components/settings/CompactVersionAdvisory.tsx +++ b/apps/web/src/components/settings/CompactVersionAdvisory.tsx @@ -10,7 +10,7 @@ import { import { useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { readLocalApi } from "../../localApi"; +import { openExternalUrl } from "../../lib/externalLinks"; import { cn } from "../../lib/utils"; import { updateSourceControlTool } from "../../lib/sourceControlDiscoveryState"; import { Button } from "../ui/button"; @@ -25,18 +25,6 @@ interface CompactVersionAdvisoryProps { readonly label: string; } -function openExternalUrl(url: string): void { - const api = readLocalApi(); - if (!api) { - window.open(url, "_blank", "noopener,noreferrer"); - return; - } - - void api.shell.openExternal(url).catch(() => { - window.open(url, "_blank", "noopener,noreferrer"); - }); -} - function advisoryTitle(advisory: SourceControlToolVersionAdvisory): string { if (advisory.status === "current") return "Up to date"; return advisory.status === "install_available" ? "Install available" : "Update available"; diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index b1a16d95c..4c05c7dec 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -123,6 +123,7 @@ import { import { providerMcpLoginCommand, type ExtensionMcpLoginProvider } from "../../mcpAuthStatus"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { PageTabButton } from "../ui/page-tabs"; import { Dialog, DialogDescription, @@ -432,40 +433,6 @@ function formatBoolean(value: boolean | undefined): string | undefined { * The page's primary structure. Underline tabs rather than chips: chips sit next to the filter * chips and read as one more filter, which is exactly what these are not. */ -function PageTabButton({ - label, - count, - active, - panelId, - onClick, -}: { - label: string; - count: number; - active: boolean; - panelId: string; - onClick: () => void; -}) { - return ( - - ); -} - /** The provider's own mark, for rows and headers that belong to exactly one provider. */ function ProviderNameGlyph({ driver }: { driver: string }) { const Glyph = providerIconForDriverLabel(driver); diff --git a/apps/web/src/components/sidebar/SidebarPullRequestsRow.tsx b/apps/web/src/components/sidebar/SidebarPullRequestsRow.tsx new file mode 100644 index 000000000..cf1b9bfd4 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarPullRequestsRow.tsx @@ -0,0 +1,73 @@ +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { GitPullRequestIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { + PULL_REQUEST_COUNT_REFETCH_INTERVAL_MS, + usePullRequestLists, +} from "../../lib/pullRequestsReactQuery"; +import { cn } from "../../lib/utils"; +import { countNeedsYou } from "../pull-requests/pullRequests.logic"; +import { useSidebar } from "../ui/sidebar"; + +/** + * The Pull Requests destination, directly under General Chats. + * + * Self-contained like the usage meter: the listing query lives here so a poll + * re-renders one row of chrome rather than the whole inbox. The row is absent + * entirely when no connected environment can answer, because a destination + * that only ever shows "unsupported" is not worth a permanent seat. + * + * The count is what needs the user, not the number of open pull requests: a + * total nobody has to act on is noise in the corner of the eye. + */ +export function SidebarPullRequestsRow() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const pathname = useLocation({ select: (location) => location.pathname }); + const isActive = pathname.startsWith("/pull-requests"); + const snapshot = usePullRequestLists({ + state: "open", + refetchIntervalMs: PULL_REQUEST_COUNT_REFETCH_INTERVAL_MS, + }); + const needsYouCount = countNeedsYou(snapshot.entries); + + const openPullRequests = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/pull-requests", search: { state: "open" } }); + }, [isMobile, navigate, setOpenMobile]); + + if (snapshot.environments.length === 0) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ui/page-tabs.tsx b/apps/web/src/components/ui/page-tabs.tsx new file mode 100644 index 000000000..19ffeffaf --- /dev/null +++ b/apps/web/src/components/ui/page-tabs.tsx @@ -0,0 +1,42 @@ +import { cn } from "../../lib/utils"; + +/** + * One tab in a page-level strip: text on a hairline, the active one underlined. + * Wrap the buttons in a `role="tablist"` row with `border-b border-border`; the + * -mb-px drops the active underline onto that hairline instead of above it. + */ +export function PageTabButton({ + label, + count, + active, + panelId, + onClick, +}: { + label: string; + /** Shown after the label in mono; omit when the number is not known up front. */ + count?: number; + active: boolean; + panelId?: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 35aa631dd..a4e098221 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -107,6 +107,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { authRemediationPlan: rpcClient.git.authRemediationPlan, applyAuthRemediation: rpcClient.git.applyAuthRemediation, }, + pullRequests: { + list: rpcClient.pullRequests.list, + }, orchestration: { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, diff --git a/apps/web/src/lib/externalLinks.ts b/apps/web/src/lib/externalLinks.ts new file mode 100644 index 000000000..8a5357bdd --- /dev/null +++ b/apps/web/src/lib/externalLinks.ts @@ -0,0 +1,18 @@ +import { readLocalApi } from "../localApi"; + +/** + * Opens a link outside the app: the desktop shell when there is one, a new tab + * otherwise. Falls back to the tab if the shell refuses, so a link never + * silently does nothing. + */ +export function openExternalUrl(url: string): void { + const api = readLocalApi(); + if (!api) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + + void api.shell.openExternal(url).catch(() => { + window.open(url, "_blank", "noopener,noreferrer"); + }); +} diff --git a/apps/web/src/lib/pullRequestsReactQuery.ts b/apps/web/src/lib/pullRequestsReactQuery.ts new file mode 100644 index 000000000..805036800 --- /dev/null +++ b/apps/web/src/lib/pullRequestsReactQuery.ts @@ -0,0 +1,231 @@ +import type { EnvironmentId, PullRequestListState } from "@threadlines/contracts"; +import { + keepPreviousData, + queryOptions, + useQueries, + type QueryClient, +} from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; +import { + mergePullRequestListResults, + type PullRequestEntry, + type PullRequestProjectFailure, +} from "~/components/pull-requests/pullRequests.logic"; +import { ensureEnvironmentApi } from "~/environmentApi"; +import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "~/environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "~/environments/runtime"; + +/** Matches the server's own listing cache, so a remount does not re-run `gh`. */ +const PULL_REQUEST_STALE_TIME_MS = 30_000; + +/** The page keeps the list current while it is on screen. */ +export const PULL_REQUEST_PAGE_REFETCH_INTERVAL_MS = 60_000; + +/** The sidebar count only has to be roughly right. */ +export const PULL_REQUEST_COUNT_REFETCH_INTERVAL_MS = 300_000; + +export const pullRequestQueryKeys = { + all: ["pull-requests"] as const, + list: (environmentId: EnvironmentId, state: PullRequestListState) => + ["pull-requests", "list", environmentId, state] as const, +}; + +/** An environment whose server can answer a pull request listing. */ +export interface PullRequestEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function pullRequestListQueryOptions(input: { + readonly environmentId: EnvironmentId; + readonly state: PullRequestListState; +}) { + return queryOptions({ + queryKey: pullRequestQueryKeys.list(input.environmentId, input.state), + queryFn: () => + ensureEnvironmentApi(input.environmentId).pullRequests.list({ state: input.state }), + staleTime: PULL_REQUEST_STALE_TIME_MS, + refetchOnWindowFocus: true, + // A refresh keeps the rows on screen: the list updates, it does not blink. + placeholderData: keepPreviousData, + }); +} + +/** + * The refresh button. Writes through the same key the page and the sidebar + * read, so one round trip updates both and the server drops its own cache + * first rather than replaying the answer the user just rejected. + */ +export async function refreshPullRequestList( + queryClient: QueryClient, + input: { readonly environmentId: EnvironmentId; readonly state: PullRequestListState }, +): Promise { + await queryClient.fetchQuery({ + ...pullRequestListQueryOptions(input), + queryFn: () => + ensureEnvironmentApi(input.environmentId).pullRequests.list({ + state: input.state, + force: true, + }), + staleTime: 0, + }); +} + +/** + * Every environment that can serve the page: this device plus each connected + * saved computer whose server reports the capability. A server too old to know + * about pull requests omits the key, and an absent capability is a no. + */ +export function usePullRequestEnvironments(): readonly PullRequestEnvironment[] { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryDescriptor = readPrimaryEnvironmentDescriptor(); + const primarySupported = primaryDescriptor?.capabilities.pullRequests === true; + const primaryLabel = primaryDescriptor?.label ?? null; + const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + + return useMemo(() => { + const environments: PullRequestEnvironment[] = []; + const seen = new Set(); + + if (primaryEnvironmentId && primarySupported) { + seen.add(primaryEnvironmentId); + environments.push({ + environmentId: primaryEnvironmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironmentId, + runtimeLabel: primaryLabel, + }), + }); + } + + for (const environmentId of Object.keys(savedEnvironmentRuntimeById) as EnvironmentId[]) { + if (seen.has(environmentId)) continue; + const runtime = savedEnvironmentRuntimeById[environmentId]; + if (!runtime || runtime.connectionState !== "connected") continue; + const descriptor = runtime.descriptor; + if (!descriptor || descriptor.capabilities.pullRequests !== true) continue; + seen.add(environmentId); + environments.push({ + environmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId, + runtimeLabel: descriptor.label, + savedLabel: savedEnvironmentsById[environmentId]?.label ?? null, + }), + }); + } + + return environments.toSorted((left, right) => + left.environmentId.localeCompare(right.environmentId), + ); + }, [ + primaryEnvironmentId, + primaryLabel, + primarySupported, + savedEnvironmentRuntimeById, + savedEnvironmentsById, + ]); +} + +/** What one environment could not do, kept next to what the others returned. */ +export interface PullRequestEnvironmentFailure { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly message: string; +} + +export interface PullRequestListSnapshot { + readonly environments: readonly PullRequestEnvironment[]; + readonly entries: readonly PullRequestEntry[]; + readonly failures: readonly PullRequestProjectFailure[]; + readonly environmentFailures: readonly PullRequestEnvironmentFailure[]; + readonly viewer: string | null; + /** Nothing to show yet, not even a previous answer. */ + readonly isPending: boolean; + readonly isFetching: boolean; +} + +function describeListFailure(reason: unknown): string { + if (reason instanceof Error && reason.message.trim().length > 0) { + return reason.message; + } + return "Unavailable"; +} + +/** + * The page's and the sidebar's shared read. Every environment is asked in + * parallel on the same keys, so whichever surface is mounted keeps the other + * one warm, and one unreachable computer costs a notice rather than the list. + */ +export function usePullRequestLists(input: { + readonly state: PullRequestListState; + readonly refetchIntervalMs: number; + readonly enabled?: boolean; +}): PullRequestListSnapshot { + const environments = usePullRequestEnvironments(); + const enabled = input.enabled ?? true; + const state = input.state; + + return useQueries({ + queries: environments.map((environment) => ({ + ...pullRequestListQueryOptions({ environmentId: environment.environmentId, state }), + enabled, + refetchInterval: input.refetchIntervalMs, + // A background tab drives no decisions, and every poll spawns `gh`. + refetchIntervalInBackground: false, + })), + combine: (results) => { + // Placeholder data here is the previous tab's answer, held over while the + // new one loads. Its rows are all filtered out by the state check, so + // reading it would turn a tab switch into a flash of "nothing to show" + // instead of the loading rows. + const usableData = results.map((result) => + result.isPlaceholderData ? undefined : result.data, + ); + const merged = mergePullRequestListResults({ + state, + results: results.flatMap((_result, index) => { + const environment = environments[index]; + return environment + ? [ + { + environmentId: environment.environmentId, + environmentLabel: environment.label, + data: usableData[index], + }, + ] + : []; + }), + }); + + return { + environments, + entries: merged.entries, + failures: merged.failures, + environmentFailures: results.flatMap((result, index) => { + const environment = environments[index]; + return result.error && environment + ? [ + { + environmentId: environment.environmentId, + label: environment.label, + message: describeListFailure(result.error), + }, + ] + : []; + }), + viewer: merged.viewer, + isPending: results.length > 0 && usableData.every((data) => data === undefined), + isFetching: results.some((result) => result.isFetching), + }; + }, + }); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b8c92dbf6..7a5bbb434 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as ChatChatsRouteImport } from './routes/_chat.chats' +import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatUsageRouteImport } from './routes/_chat.usage' import { Route as SettingsIndexRouteImport } from './routes/settings.index' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' @@ -52,6 +53,11 @@ const ChatChatsRoute = ChatChatsRouteImport.update({ path: '/chats', getParentRoute: () => ChatRoute, } as any) +const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ + id: '/pull-requests', + path: '/pull-requests', + getParentRoute: () => ChatRoute, +} as any) const ChatUsageRoute = ChatUsageRouteImport.update({ id: '/usage', path: '/usage', @@ -124,6 +130,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/chats': typeof ChatChatsRoute + '/pull-requests': typeof ChatPullRequestsRoute '/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -141,6 +148,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/chats': typeof ChatChatsRoute + '/pull-requests': typeof ChatPullRequestsRoute '/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -162,6 +170,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/_chat/chats': typeof ChatChatsRoute + '/_chat/pull-requests': typeof ChatPullRequestsRoute '/_chat/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute @@ -184,6 +193,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/chats' + | '/pull-requests' | '/usage' | '/settings/archived' | '/settings/connections' @@ -201,6 +211,7 @@ export interface FileRouteTypes { to: | '/pair' | '/chats' + | '/pull-requests' | '/usage' | '/settings/archived' | '/settings/connections' @@ -221,6 +232,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/_chat/chats' + | '/_chat/pull-requests' | '/_chat/usage' | '/settings/archived' | '/settings/connections' @@ -280,6 +292,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatChatsRouteImport parentRoute: typeof ChatRoute } + '/_chat/pull-requests': { + id: '/_chat/pull-requests' + path: '/pull-requests' + fullPath: '/pull-requests' + preLoaderRoute: typeof ChatPullRequestsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/usage': { id: '/_chat/usage' path: '/usage' @@ -376,6 +395,7 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatChatsRoute: typeof ChatChatsRoute + ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatUsageRoute: typeof ChatUsageRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute @@ -384,6 +404,7 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatChatsRoute: ChatChatsRoute, + ChatPullRequestsRoute: ChatPullRequestsRoute, ChatUsageRoute: ChatUsageRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx new file mode 100644 index 000000000..a3ab10506 --- /dev/null +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; + +import { PullRequestsView } from "../components/pull-requests/PullRequestsView"; +import { parsePullRequestsSearch } from "../components/pull-requests/pullRequests.logic"; + +function PullRequestsRoute() { + const { state } = Route.useSearch(); + const navigate = useNavigate(); + + return ( + { + // Replace rather than push: flipping between Open and Merged is a view + // change, not a place the back button should walk through. + void navigate({ to: "/pull-requests", search: { state: nextState }, replace: true }); + }} + /> + ); +} + +export const Route = createFileRoute("/_chat/pull-requests")({ + validateSearch: (search: Record) => parsePullRequestsSearch(search), + component: PullRequestsRoute, +}); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 744cb21c4..917a23ef3 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -178,6 +178,13 @@ export interface WsRpcClient { readonly authRemediationPlan: RpcUnaryMethod; readonly applyAuthRemediation: RpcUnaryMethod; }; + /** + * Pull requests read from the hosting provider through the server. One call + * covers every eligible project in the environment. + */ + readonly pullRequests: { + readonly list: RpcUnaryMethod; + }; readonly server: { readonly getConfig: RpcUnaryNoArgMethod; /** @@ -554,6 +561,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { applyAuthRemediation: (input) => transport.request((client) => client[WS_METHODS.gitApplyAuthRemediation](input)), }, + pullRequests: { + list: (input) => transport.request((client) => client[WS_METHODS.pullRequestsList](input)), + }, server: { // Pure read, and the only thing standing between "Add computer" and a // closed dialog. A plain request is pinned to the transport session it diff --git a/docs/design/pull-requests.md b/docs/design/pull-requests.md new file mode 100644 index 000000000..66238a617 --- /dev/null +++ b/docs/design/pull-requests.md @@ -0,0 +1,271 @@ +# Pull requests page + +A page that lists the pull requests of every GitHub project in the workspace, grouped by what +needs the user, with each row tied back to the thread that produced it. Reached from a row under +"General Chats" in the sidebar. + +This document is the build spec for step 1 (list only). Steps 2 (detail in the right panel) and 3 +(review actions, merged threads move to Done) come later and are out of scope here. + +## Principles + +- **Inbox, not a table.** The list answers "what needs me" first. Everything else is below it. +- **Threads are the unit of work.** A PR whose branch belongs to a thread shows that thread and + opens it. A PR with no thread offers "Review in a thread", which is the existing checkout flow. +- **GitHub only, behind an interface.** The server reads through `gh`. Other hosts are skipped + silently for now; the service shape leaves room for them. +- **Cheap on the host.** One `gh pr list` per repository, cached for 30 seconds, refreshed only + while the page is open or on explicit refresh. The sidebar count shares the same cache. +- **Dense and flat.** Divider rows, mono meta line, colour-shift hover, no pills, no cards. + +## Contracts (`packages/contracts/src/pullRequest.ts`, exported from `index.ts`) + +``` +PullRequestListState = "open" | "merged" | "closed" +PullRequestState = "open" | "merged" | "closed" +PullRequestReviewDecision = "approved" | "changes-requested" | "review-required" +PullRequestChecksState = "pending" | "success" | "failure" +PullRequestActor = { login: TrimmedNonEmptyString; isBot: boolean } +PullRequestLabel = { name: TrimmedNonEmptyString; color: string | null } // hex without '#', as gh reports it + +PullRequestListEntry = { + provider: SourceControlProviderKind // "github" for now + projectId: ProjectId + projectTitle: TrimmedNonEmptyString + repository: TrimmedNonEmptyString // "owner/name" + number: PositiveInt + title: TrimmedNonEmptyString + url: TrimmedNonEmptyString + author: PullRequestActor | null + headBranch: TrimmedNonEmptyString + baseBranch: TrimmedNonEmptyString + state: PullRequestState + isDraft: boolean + additions: NonNegativeInt + deletions: NonNegativeInt + createdAt: IsoDateTime + updatedAt: IsoDateTime + viewerIsAuthor: boolean + viewerReviewRequested: boolean + reviewDecision?: PullRequestReviewDecision // absent when gh reports none + checksState?: PullRequestChecksState // absent when no checks or not requested + labels: PullRequestLabel[] +} + +PullRequestListProjectError = { + projectId: ProjectId + projectTitle: TrimmedNonEmptyString + repository: string | null + reason: "missing-tool" | "unauthenticated" | "rate-limited" | "failed" + detail: string +} + +PullRequestListInput = { state: PullRequestListState; projectId?: ProjectId; force?: boolean } +PullRequestListResult = { + viewer: string | null // signed-in gh login for github.com, null if unknown + entries: PullRequestListEntry[] + errors: PullRequestListProjectError[] +} +``` + +Error type for the RPC: a `Schema.TaggedError` named `PullRequestServiceError` with +`{ operation: string; detail: string }`, in the same style as `GitManagerServiceError`. + +RPC: `WS_METHODS.pullRequestsList = "pullRequests.list"`, `WsPullRequestsListRpc` added to the +`WsRpcGroup` in `rpc.ts`. `EnvironmentApi` (`ipc.ts`) gains `pullRequests: { list }`. + +Capability: `ExecutionEnvironmentCapabilities` gains `pullRequests: Schema.optionalKey(Schema.Boolean)`. +The server sets it to `true` in `ServerEnvironment.ts`. Older servers omit it; the client treats +absent as unsupported. + +## Server (`apps/server/src/pullRequest/`) + +`gitHubPullRequestList.ts`: pure decoding of `gh pr list --json` output into +`PullRequestListEntry` fields (no I/O). Fields requested: + +``` +number,title,url,author,headRefName,baseRefName,state,isDraft,additions,deletions,createdAt,updatedAt,mergedAt,reviewDecision,reviewRequests,labels,statusCheckRollup +``` + +- `state`: `MERGED` or a non-null `mergedAt` → `merged`; `CLOSED` → `closed`; else `open`. +- `reviewDecision`: `APPROVED` → `approved`, `CHANGES_REQUESTED` → `changes-requested`, + `REVIEW_REQUIRED` → `review-required`; empty or unknown → absent. +- `reviewRequests`: collect user logins only (entries with `__typename: "User"` or a `login`); + team requests are ignored. +- `statusCheckRollup` (array of checks) → one word: any `FAILURE`/`ERROR`/`TIMED_OUT`/`CANCELLED` + conclusion → `failure`; any check without a completed status → `pending`; all + `SUCCESS`/`SKIPPED`/`NEUTRAL` → `success`; empty array → absent. +- `statusCheckRollup` is expensive on big repositories, so it is requested only for `state: "open"`. + Merged and closed listings omit the field and report no `checksState`. +- Malformed rows are skipped, not fatal. A whole payload that fails to parse is a `failed` error. + +`PullRequestService.ts` (Effect `Context.Service`, layer in `server.ts`): + +- `list(input)`: + 1. Read projects from `ProjectionSnapshotQuery.getShellSnapshot()`. Keep projects with + `kind !== "general-chat"` whose `repositoryIdentity` has `provider === "github"` and both + `owner` and `name`. If `input.projectId` is set, keep only that project. Other projects are + skipped without an error entry. + 2. Viewer: run `gh auth status --json hosts` once, parse with `parseGitHubAuthStatus`, take the + active authenticated account for `github.com` (`findAuthenticatedGitHubAccount` if it fits). + Cache for 10 minutes. Null if unavailable; do not fail the listing for it. + 3. Per project, concurrency 4: `gh pr list -R owner/name --state --limit --json ` + with `cwd = workspaceRoot`, through `GitHubCli.execute`. N is 50 for open, 30 for merged and + closed. Map rows with `gitHubPullRequestList.ts`; set `viewerIsAuthor` and + `viewerReviewRequested` by comparing logins to the viewer (case-insensitive). + 4. A failing project becomes one `errors` entry and the rest still return. Classify from the + CLI error text: `gh` not found → `missing-tool`; "not logged in" / "authentication" / + "auth login" → `unauthenticated`; "rate limit" → `rate-limited`; else `failed`. + 5. Cache results in an Effect `Cache` keyed by `${state}|${projectId ?? "*"}`, TTL 30 seconds, + capacity 32. `force: true` invalidates the key first. Concurrent identical reads share one + lookup (the Cache does this). +- Entries are returned in the order gh gives them; the client sorts. + +`ws.ts`: `[WS_METHODS.pullRequestsList]: (input) => observeRpcEffect(..., pullRequests.list(input), { "rpc.aggregate": "pullRequests" })`. + +Tests (`PullRequestService.test.ts`, `gitHubPullRequestList.test.ts`), using `Layer.mock` for +`GitHubCli` and `ProjectionSnapshotQuery` like `GitHubCli.test.ts` does for `VcsProcess`: + +- A GitHub project and a non-GitHub project: only the GitHub one is listed; no error for the other. +- Viewer flags: author equal to viewer → `viewerIsAuthor`; viewer in review requests → + `viewerReviewRequested`; a team-only request → false. +- One project's gh call fails with "not logged into any GitHub hosts": the other project's rows + still return and `errors` has one `unauthenticated` entry. +- Two `list` calls inside 30 seconds run gh once per project; `force: true` runs it again. +- Decoder: state mapping including `mergedAt`, checks rollup to one word, malformed row skipped. + +## Web + +### Data (`apps/web/src/lib/pullRequestsReactQuery.ts`) + +react-query, in the style of `gitReactQuery.ts`: + +- `pullRequestQueryKeys.list(environmentId, state)`. +- `pullRequestListQueryOptions({ environmentId, state })`: `staleTime` 30 s, `refetchOnWindowFocus` + true, `placeholderData: keepPreviousData`. The page passes `refetchInterval` 60 s; the sidebar + count passes 5 min. Both read the same key, so whichever is mounted keeps it warm. +- Refresh button: call the RPC with `force: true` through `queryClient.fetchQuery` on the same key + (or a mutation that writes to the key), so the cache and the UI update together. +- Environments: from `useSavedEnvironmentRuntimeStore` `byId`, take environments whose + `connectionState` is connected and whose `descriptor.capabilities.pullRequests === true`. Query + each with `useQueries` and merge. One environment failing degrades to a notice, never a blank + page. + +`environmentApi.ts` and `rpc/wsRpcClient.ts` gain `pullRequests.list`. + +### Logic (`apps/web/src/components/pull-requests/pullRequests.logic.ts`, pure, unit-tested) + +- `linkThreadsToPullRequests(entries, threads)`: for each entry, threads with the same + `environmentId` + `projectId`, `archivedAt === null`, and `branch === headBranch`. Sorted most + recently updated first. Threads come from `selectSidebarThreadsAcrossEnvironments`. +- `resolveNeedsYouReason(entry)` (open state only), first match wins: + - `viewerReviewRequested` → "Review requested" + - `viewerIsAuthor && reviewDecision === "changes-requested"` → "Changes requested" + - `viewerIsAuthor && checksState === "failure"` → "Checks failing" + - `viewerIsAuthor && reviewDecision === "approved" && !isDraft` → "Approved" + - otherwise null. +- `groupPullRequests(entries, viewer)` for the open state → `[needsYou, yours, others]`, each + sorted by `updatedAt` desc; empty groups are omitted. A row is in exactly one group. + `yours` = `viewerIsAuthor`. When the viewer is null, everything is "others" and the group + header is omitted (a single flat list). Merged and closed states are one flat list, newest + update first. +- `matchesPullRequestQuery(entry, query)`: case-insensitive match on title, `#number` or bare + number, author login, head branch, repository, label names. Words are ANDed. +- `countNeedsYou(entries)` for the sidebar. + +### Page (`apps/web/src/components/pull-requests/PullRequestsView.tsx`, route `routes/_chat.pull-requests.tsx`) + +Mirror `ChatsDestinationView.tsx`: `DesktopPageTitlebar label="Pull requests"`, a pane-wide +scroller, a centred reading column (`max-w-3xl`, this list is wider than a chat list). + +Header block: + +- `h1` "Pull requests" (same size as General chats). To its right, a segmented control + (`ui/toggle-group`, size and variant as the existing segmented usage) with **Open**, **Merged**, + **Closed**. Default Open. The state lives in the route search param `state`. +- One line of muted copy under the title: "Across every project with a GitHub remote." +- Below: a search `Input` (placeholder "Search title, #number, author, branch, label") with a + refresh icon button at its right (`RefreshCwIcon`, spins while fetching, tooltip "Refresh"). + Search is local, instant, and lives in component state (not the URL). + +List: + +- Group header: same voice as the General chats page (`font-mono text-[10px] uppercase +tracking-wider text-muted-foreground/55`), text "Needs you · 3", "Yours · 5", "Others · 12". +- Rows separated by `divide-y divide-border/50`. Each row is a `button` (`hover:bg-muted`, + `rounded-md`, `py-2.5`, same as `ChatRow`) laid out as a grid: glyph column, content column. +- Glyph: `GitPullRequestIcon` emerald for open (reuse the exact classes from + `ThreadStatusIndicators`), `GitPullRequestDraftIcon` muted for draft, `GitMergeIcon` violet for + merged, `GitPullRequestClosedIcon` zinc for closed. `size-4`. +- Line 1: title (`text-sm font-medium text-foreground/90`, truncate). Right end: relative time from + `formatRelativeTimeLabel(updatedAt)` in `font-mono text-xs tabular-nums text-muted-foreground/50`. +- Line 2 (`text-xs text-muted-foreground/55`, single line, truncating from the left cluster): + `#123`, then `owner/name` only when the list spans more than one repository, then author login, + then the environment label only when more than one environment contributes. Then the needs-you + reason in amber (`text-amber-600/90 dark:text-amber-400/80`), except "Approved" in emerald. Then + up to two labels as plain text each preceded by a 6 px dot in the label colour, "+n" for the rest. + Right end: `DiffStatLabel` (`font-mono text-xs`) when additions or deletions are non-zero. +- Thread link: when the row has linked threads, a third element on line 2's right side, before the + diff stat: `MessagesSquareIcon` + the thread title (truncate, max 40 % of the row). It reads as + "this PR is being worked in that thread". +- Click on the row: with a linked thread, navigate to it (`buildThreadRouteParams`, same as + `ChatRow`). Without one, open the PR on GitHub (`window.open` with `noopener`, or the + existing external-link helper if one exists). +- Hover actions at the row's right end, revealed on hover or focus-within, always visible for coarse + pointers (same technique as the inbox rows): **Open on GitHub** (`ExternalLinkIcon`) and + **Review in a thread** (`GitBranchPlusIcon`). Tooltips carry those labels. "Review in a thread" + opens `PullRequestThreadDialog` with `environmentId`, `cwd = project.cwd`, `threadId = newThreadId()`, + `initialReference = entry.url`. `onPrepared` calls `handleNewThread` from `useNewThreadHandler()` + with `scopeProjectRef(entry.environmentId, entry.projectId)` and + `{ branch, worktreePath, envMode: worktreePath ? "worktree" : "local" }`. + +States: + +- Loading with nothing cached: three skeleton rows (see `SidebarInboxLoadingSkeleton`). +- No connected environment supports pull requests: "Pull requests need a newer Threadlines server." +- No GitHub project anywhere: "Add a project with a GitHub remote to see its pull requests." +- Every project failed with `missing-tool` or `unauthenticated`: title "Sign in to GitHub CLI", + one line "Threadlines reads pull requests through gh on the server.", and an outline button + "Open Source Control settings" that navigates to `/settings/source-control`. +- Some projects failed: a single muted notice line above the list, "Couldn't load 2 projects", with + the project titles and details in a tooltip or a collapsible line, and a "Retry" text button. +- Nothing to show: "No open pull requests." / "Nothing merged recently." / "Nothing closed recently." +- Search with no match: "No pull requests match." + +Empty states use the `ui/empty` primitives. + +### Sidebar row (`Sidebar.tsx`) + +Directly under the General Chats row, as its sibling, same anatomy and classes: +`GitPullRequestIcon` `size-3.5`, label "Pull Requests", `data-testid="sidebar-pull-requests"`, +`aria-current="page"` when the pathname starts with `/pull-requests`. Tighten the General Chats +wrapper's bottom margin so the two rows read as a pair, and give the pair the existing gap below. + +Right end of the row: the needs-you count when it is greater than zero, `font-mono text-[10px] +tabular-nums text-muted-foreground/60`. Hover does not change it (no "new" affordance). + +The row is hidden entirely when no connected environment reports the capability. + +Click: close the mobile sheet, navigate to `/pull-requests` (mirror `handleOpenChats`). + +### Command palette (`CommandPalette.tsx`) + +Action `action:pull-requests`, title "Open pull requests", search terms +`["pull requests", "prs", "pr", "reviews", "github"]`, `GitPullRequestIcon`, navigates to +`/pull-requests`. Registered only when the row would be visible. + +### Tests + +- `pullRequests.logic.test.ts`: grouping and reasons (one row per group, first-match rule), thread + linking (archived excluded, branch must match, project must match), query matching. +- `PullRequestsView.browser.tsx`: with `__setEnvironmentApiOverrideForTests` stubbing + `pullRequests.list`: renders three groups with the right counts; the sign-in empty state when + every project is `unauthenticated`; clicking "Review in a thread" opens the dialog with the PR + URL prefilled. Keep it to those three. +- Regenerate `routeTree.gen.ts` the way the router plugin does (check `apps/web/vite.config.ts` + and the scripts before hand-editing it; it is checked in). + +## Out of scope for step 1 + +Detail panel, diffs, comments, reviews, merge, linked-PR persistence on the thread, settling merged +threads, seeding the composer with the PR context, hosts other than GitHub, keybinding. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index fb52972d5..67160e699 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -22,6 +22,8 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Older servers omit the key; clients read an absent value as unsupported. */ + pullRequests: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 150ac186b..2b08b4fba 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./pullRequest.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 22de2a50f..652912ae0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -53,6 +53,7 @@ import type { VcsStatusLocalResult, VcsStatusResult, } from "./git.ts"; +import type { PullRequestListInput, PullRequestListResult } from "./pullRequest.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { UsageSummary, UsageSummaryInput } from "./usage.ts"; import type { @@ -1358,6 +1359,9 @@ export interface EnvironmentApi { input: GitApplyAuthRemediationInput, ) => Promise; }; + pullRequests: { + list: (input: PullRequestListInput) => Promise; + }; orchestration: { dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts new file mode 100644 index 000000000..def50b6c1 --- /dev/null +++ b/packages/contracts/src/pullRequest.ts @@ -0,0 +1,119 @@ +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { SourceControlProviderKind } from "./sourceControl.ts"; + +/** Which slice of a project's pull requests a listing asks for. */ +export const PullRequestListState = Schema.Literals(["open", "merged", "closed"]); +export type PullRequestListState = typeof PullRequestListState.Type; + +export const PullRequestState = Schema.Literals(["open", "merged", "closed"]); +export type PullRequestState = typeof PullRequestState.Type; + +export const PullRequestReviewDecision = Schema.Literals([ + "approved", + "changes-requested", + "review-required", +]); +export type PullRequestReviewDecision = typeof PullRequestReviewDecision.Type; + +export const PullRequestChecksState = Schema.Literals(["pending", "success", "failure"]); +export type PullRequestChecksState = typeof PullRequestChecksState.Type; + +export const PullRequestActor = Schema.Struct({ + login: TrimmedNonEmptyString, + isBot: Schema.Boolean, +}); +export type PullRequestActor = typeof PullRequestActor.Type; + +/** `color` is the hex triplet without a leading `#`, exactly as the host reports it. */ +export const PullRequestLabel = Schema.Struct({ + name: TrimmedNonEmptyString, + color: Schema.NullOr(Schema.String), +}); +export type PullRequestLabel = typeof PullRequestLabel.Type; + +/** + * One pull request as the pull requests page renders it: the host's fields plus + * the project it belongs to and how it relates to the signed-in viewer. + */ +export const PullRequestListEntry = Schema.Struct({ + provider: SourceControlProviderKind, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + /** `owner/name`. */ + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(PullRequestActor), + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + state: PullRequestState, + isDraft: Schema.Boolean, + additions: NonNegativeInt, + deletions: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + viewerIsAuthor: Schema.Boolean, + viewerReviewRequested: Schema.Boolean, + /** Absent when the host reports no decision. */ + reviewDecision: Schema.optionalKey(PullRequestReviewDecision), + /** Absent when there are no checks, or when checks were not requested. */ + checksState: Schema.optionalKey(PullRequestChecksState), + labels: Schema.Array(PullRequestLabel), +}); +export type PullRequestListEntry = typeof PullRequestListEntry.Type; + +export const PullRequestListProjectErrorReason = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "failed", +]); +export type PullRequestListProjectErrorReason = typeof PullRequestListProjectErrorReason.Type; + +/** One project the listing could not read. The other projects still return. */ +export const PullRequestListProjectError = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + repository: Schema.NullOr(TrimmedNonEmptyString), + reason: PullRequestListProjectErrorReason, + detail: Schema.String, +}); +export type PullRequestListProjectError = typeof PullRequestListProjectError.Type; + +export const PullRequestListInput = Schema.Struct({ + state: PullRequestListState, + /** Limits the listing to one project; every eligible project otherwise. */ + projectId: Schema.optionalKey(ProjectId), + /** Drops the cached result for this listing before reading. */ + force: Schema.optionalKey(Schema.Boolean), +}); +export type PullRequestListInput = typeof PullRequestListInput.Type; + +export const PullRequestListResult = Schema.Struct({ + /** The signed-in host login, or null when it could not be determined. */ + viewer: Schema.NullOr(Schema.String), + entries: Schema.Array(PullRequestListEntry), + errors: Schema.Array(PullRequestListProjectError), +}); +export type PullRequestListResult = typeof PullRequestListResult.Type; + +export class PullRequestServiceError extends Schema.TaggedError()( + "PullRequestServiceError", + { + operation: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Pull request service failed in ${this.operation}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6ef531121..9858095c3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -76,6 +76,11 @@ import { VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; +import { + PullRequestListInput, + PullRequestListResult, + PullRequestServiceError, +} from "./pullRequest.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { ChatAttachmentReadError, @@ -300,6 +305,9 @@ export const WS_METHODS = { gitAuthRemediationPlan: "git.authRemediationPlan", gitApplyAuthRemediation: "git.applyAuthRemediation", + // Pull request methods + pullRequestsList: "pullRequests.list", + // Terminal methods terminalOpen: "terminal.open", terminalWrite: "terminal.write", @@ -911,6 +919,12 @@ export const WsGitApplyAuthRemediationRpc = Rpc.make(WS_METHODS.gitApplyAuthReme error: GitManagerServiceError, }); +export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { + payload: PullRequestListInput, + success: PullRequestListResult, + error: PullRequestServiceError, +}); + export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { payload: VcsListRefsInput, success: VcsListRefsResult, @@ -1247,6 +1261,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitPreparePullRequestThreadRpc, WsGitAuthRemediationPlanRpc, WsGitApplyAuthRemediationRpc, + WsPullRequestsListRpc, WsVcsListRefsRpc, WsVcsCommitGraphRpc, WsVcsCommitDetailsRpc, From e0eae1bf94c37c1f1c83cbcbbc92eba10e6eff43 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:03:36 -0400 Subject: [PATCH 2/5] feat(server): pull request service, host providers, and RPCs Adds a host-neutral pull request service behind a provider port: GitHub through gh, with GitLab, Bitbucket, and Azure DevOps behind per-host capability tables. Lists, detail, activity, diff, comments, reviews, reactions, edits, reviewers, update branch, auto-merge, and the viewer's own pull requests on repositories outside the workspace. Reads sit behind short caches so the page can poll without hammering the host. --- .../AzureDevOpsPullRequestProvider.test.ts | 214 +++ .../AzureDevOpsPullRequestProvider.ts | 503 ++++++++ .../BitbucketPullRequestProvider.test.ts | 187 +++ .../BitbucketPullRequestProvider.ts | 612 +++++++++ .../GitHubPullRequestProvider.test.ts | 221 ++++ .../pullRequest/GitHubPullRequestProvider.ts | 723 +++++++++++ .../GitLabPullRequestProvider.test.ts | 254 ++++ .../pullRequest/GitLabPullRequestProvider.ts | 761 +++++++++++ .../src/pullRequest/PullRequestProvider.ts | 307 +++++ .../PullRequestProviderRegistry.ts | 28 + .../pullRequest/PullRequestService.test.ts | 778 ++++++++++- .../src/pullRequest/PullRequestService.ts | 1144 ++++++++++++++--- .../azureDevOpsPullRequest.test.ts | 161 +++ .../src/pullRequest/azureDevOpsPullRequest.ts | 399 ++++++ .../pullRequest/bitbucketPullRequest.test.ts | 266 ++++ .../src/pullRequest/bitbucketPullRequest.ts | 799 ++++++++++++ .../gitHubPullRequestDetail.test.ts | 208 +++ .../pullRequest/gitHubPullRequestDetail.ts | 425 ++++++ .../gitHubPullRequestGraphql.test.ts | 341 +++++ .../pullRequest/gitHubPullRequestGraphql.ts | 897 +++++++++++++ .../src/pullRequest/gitHubPullRequestList.ts | 104 +- .../pullRequest/gitLabMergeRequest.test.ts | 319 +++++ .../src/pullRequest/gitLabMergeRequest.ts | 1119 ++++++++++++++++ .../src/pullRequest/pullRequestDiff.test.ts | 53 + .../server/src/pullRequest/pullRequestDiff.ts | 36 + apps/server/src/server.ts | 21 +- apps/server/src/sourceControl/BitbucketApi.ts | 49 + apps/server/src/sourceControl/GitHubCli.ts | 10 + apps/server/src/sourceControl/GitLabCli.ts | 10 + .../sourceControl/azureDevOpsPullRequests.ts | 47 +- apps/server/src/ws.ts | 66 + apps/web/src/components/PageTitlebar.tsx | 66 + packages/contracts/src/ipc.ts | 38 +- packages/contracts/src/pullRequest.ts | 475 +++++++ packages/contracts/src/rpc.ts | 130 ++ packages/shared/src/sourceControl.ts | 63 +- 36 files changed, 11575 insertions(+), 259 deletions(-) create mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts create mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts create mode 100644 apps/server/src/pullRequest/BitbucketPullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts create mode 100644 apps/server/src/pullRequest/GitHubPullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts create mode 100644 apps/server/src/pullRequest/GitLabPullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/PullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/PullRequestProviderRegistry.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsPullRequest.test.ts create mode 100644 apps/server/src/pullRequest/azureDevOpsPullRequest.ts create mode 100644 apps/server/src/pullRequest/bitbucketPullRequest.test.ts create mode 100644 apps/server/src/pullRequest/bitbucketPullRequest.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestDetail.test.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestDetail.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestGraphql.ts create mode 100644 apps/server/src/pullRequest/gitLabMergeRequest.test.ts create mode 100644 apps/server/src/pullRequest/gitLabMergeRequest.ts create mode 100644 apps/server/src/pullRequest/pullRequestDiff.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestDiff.ts create mode 100644 apps/web/src/components/PageTitlebar.tsx diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 000000000..7d0bc267f --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, afterEach, describe, 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 type * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockExecute = vi.fn(); + +const layer = Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ execute: mockExecute }); + +// The recorded path of an Azure remote; `az` takes the repository's own name and +// reads the organisation and project from the checkout it detects. +const repository = { cwd: "/workspaces/tools", repository: "acme/Platform/_git/tools" }; + +const calls = () => mockExecute.mock.calls.map(([input]) => input); + +afterEach(() => { + mockExecute.mockReset(); +}); + +describe("AzureDevOpsPullRequestProvider.listChangeRequests", () => { + it.effect("asks for the repository by its own name, dropping the recorded path", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput("[]"))); + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + yield* provider.listChangeRequests({ ...repository, state: "merged", limit: 30 }); + + assert.deepStrictEqual(calls()[0]?.args, [ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "tools", + "--status", + "completed", + "--include-links", + "--top", + "30", + "--only-show-errors", + "--output", + "json", + ]); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("AzureDevOpsPullRequestProvider.runAction", () => { + const cases = [ + { + name: "merges by completing the pull request, squashing when asked", + input: { action: "merge", mergeMethod: "squash" }, + args: ["--status", "completed", "--squash", "true"], + }, + { + name: "arms auto-complete with the squash choice stored alongside", + input: { action: "enable-auto-merge", mergeMethod: "merge" }, + args: ["--auto-complete", "true", "--squash", "false"], + }, + { + name: "disarms auto-complete", + input: { action: "disable-auto-merge" }, + args: ["--auto-complete", "false"], + }, + { + name: "turns a pull request back into a draft", + input: { action: "draft" }, + args: ["--draft", "true"], + }, + { + name: "reopens by reactivating", + input: { action: "reopen" }, + args: ["--status", "active"], + }, + ] as const; + + for (const testCase of cases) { + it.effect(testCase.name, () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + yield* provider.runAction({ ...repository, number: 7, ...testCase.input }); + + assert.deepStrictEqual(calls()[0]?.args, [ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "7", + ...testCase.args, + "--only-show-errors", + "--output", + "json", + ]); + }).pipe(Effect.provide(layer)), + ); + } +}); + +describe("AzureDevOpsPullRequestProvider.updateChangeRequest", () => { + it.effect("keeps the new words in one argument, so a leading dash is not read as a flag", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + yield* provider.updateChangeRequest({ + ...repository, + number: 7, + body: "- Tidies the toolbox", + }); + + assert.deepStrictEqual(calls()[0]?.args, [ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "7", + "--description=- Tidies the toolbox", + "--only-show-errors", + "--output", + "json", + ]); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("AzureDevOpsPullRequestProvider.setReviewerRequest", () => { + it.effect("takes a request back with one --reviewers, which az reads as a list", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + yield* provider.setReviewerRequest({ + ...repository, + number: 7, + reviewers: [ + { id: "hubot@acme.test", kind: "user" }, + { id: "monalisa@acme.test", kind: "user" }, + ], + requested: false, + }); + + assert.deepStrictEqual(calls()[0]?.args, [ + "repos", + "pr", + "reviewer", + "remove", + "--detect", + "true", + "--id", + "7", + "--reviewers", + "hubot@acme.test", + "monalisa@acme.test", + "--only-show-errors", + "--output", + "json", + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("refuses a reviewer shaped like a flag without running az", () => + Effect.gen(function* () { + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + const error = yield* provider + .setReviewerRequest({ + ...repository, + number: 7, + reviewers: [{ id: "--query", kind: "user" }], + requested: true, + }) + .pipe(Effect.flip); + + assert.equal( + error.detail, + "Azure DevOps takes a reviewer's email address, display name or id.", + ); + assert.deepStrictEqual(calls(), []); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("AzureDevOpsPullRequestProvider.getDiff", () => { + it.effect("refuses without running az, there being no patch to produce", () => + Effect.gen(function* () { + const provider = yield* AzureDevOpsPullRequestProvider.make(); + + const error = yield* provider.getDiff({ ...repository, number: 7 }).pipe(Effect.flip); + + assert.equal(error.detail, "Azure DevOps cannot produce a patch for a pull request."); + assert.deepStrictEqual(calls(), []); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts new file mode 100644 index 000000000..eb69a9208 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -0,0 +1,503 @@ +import type * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import type * as Schema from "effect/Schema"; + +import type { + PullRequestAction, + PullRequestActivity, + PullRequestCapabilities, + PullRequestListState, + PullRequestMergeMethod, +} from "@threadlines/contracts"; +import { formatSchemaError } from "@threadlines/shared/schemaJson"; + +import type * as AzureDevOpsCliModule from "../sourceControl/AzureDevOpsCli.ts"; +import { AzureDevOpsCli } from "../sourceControl/AzureDevOpsCli.ts"; +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + decodeAzureDevOpsRepositoryJson, + decodeAzureDevOpsThreadsJson, + decodeAzureDevOpsViewerJson, + type AzureDevOpsPullRequestRow, +} from "./azureDevOpsPullRequest.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const PROVIDER_KIND = "azure-devops" as const; +/** The REST version the threads read is pinned to; `az rest` sends no default. */ +const REST_API_VERSION = "7.1"; +/** A merge waits on Azure settling its branch policies. */ +const MERGE_TIMEOUT_MS = 60_000; + +/** + * Everything Azure DevOps lets a reader do here. `az repos pr` has no diff + * command and the REST route reports changed files without their contents, so + * there is no patch to show and the Code tab is hidden rather than empty. + * Reading a conversation is a plain REST read, but nothing in `az repos pr` + * posts one, so the composer stays hidden too. + */ +export const AZURE_DEVOPS_PULL_REQUEST_CAPABILITIES: PullRequestCapabilities = { + diff: false, + comment: false, + actions: [ + "merge", + "close", + "reopen", + "ready", + "draft", + "enable-auto-merge", + "disable-auto-merge", + ], + // Azure squashes as a completion option; it has no rebase strategy of its own. + mergeMethods: ["merge", "squash"], + updateMethods: [], + reactions: 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 in + // `az repos` lists the ones a 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 a menu built out of a guess. + reviewers: { request: true, listCandidates: false }, + // A new title and description travel on the same `az repos pr update` that + // moves a pull request. Rewriting a remark is false for the reason posting one + // is: nothing here can put a remark on Azure DevOps to rewrite. + edit: { pullRequest: true, comment: false }, +}; + +/** Turns an `az` failure into the reason the page renders an action for. */ +export function classifyAzureDevOpsFailure(detail: string): PullRequestProviderError["reason"] { + const lower = detail.toLowerCase(); + if ( + lower.includes("not available on path") || + lower.includes("command not found") || + lower.includes("enoent") + ) { + return "missing-tool"; + } + if ( + lower.includes("not authenticated") || + lower.includes("az devops login") || + lower.includes("az login") || + lower.includes("not logged in") || + lower.includes("unauthorized") || + lower.includes("authentication") + ) { + return "unauthenticated"; + } + if (lower.includes("rate limit") || lower.includes("too many requests")) { + return "rate-limited"; + } + return "failed"; +} + +/** The line of an `az` failure worth showing; the CLI stacks its own wrapper. */ +function lastFailureLine(detail: string): string { + const lines = detail + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return lines.length > 1 ? (lines[lines.length - 1] ?? detail.trim()) : detail.trim(); +} + +function toProviderError(operation: string, error: AzureDevOpsCliModule.AzureDevOpsCliError) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: classifyAzureDevOpsFailure(error.detail), + detail: lastFailureLine(error.detail), + }); +} + +function decodeError(operation: string, subject: string, failure: Cause.Cause) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: `Azure DevOps CLI returned invalid ${subject} JSON: ${formatSchemaError(failure)}`, + }); +} + +/** Refuses a call this host declares it cannot make, should one ever reach it. */ +function unsupported(operation: string, detail: string) { + return Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail, + }), + ); +} + +function statusArgs(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["--status", "active"]; + case "merged": + return ["--status", "completed"]; + case "closed": + return ["--status", "abandoned"]; + } +} + +/** + * Azure moves a pull request by setting its state rather than by named + * commands: completing it is the merge, abandoning it the close, and + * reactivating it the reopen. Squashing is a completion option of its own. + */ +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + const squash = ["--squash", mergeMethod === "squash" ? "true" : "false"]; + switch (action) { + case "merge": + return ["--status", "completed", ...squash]; + // Auto-complete is Azure's own name for it: the pull request stays active + // and Azure completes it once its policies pass, with the squash choice + // stored alongside as it is for a merge now. + case "enable-auto-merge": + return ["--auto-complete", "true", ...squash]; + case "disable-auto-merge": + return ["--auto-complete", "false"]; + case "ready": + return ["--draft", "false"]; + case "draft": + return ["--draft", "true"]; + case "close": + return ["--status", "abandoned"]; + case "reopen": + return ["--status", "active"]; + // Never reached: this host does not declare the action, so nothing offers it. + case "update-branch": + return []; + } +} + +/** + * 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 point: + * these are argv, and a value shaped like a flag would become one. + */ +function isReviewerName(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length > 0 && !trimmed.startsWith("-"); +} + +/** + * `az repos pr list` names a repository by its own name and takes the + * organization and project from the checkout it detects, so the recorded path's + * last segment is what it is handed. + */ +function repositoryName(repository: string): string { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== "_git"); + return segments.at(-1) ?? repository.trim(); +} + +export const make = Effect.fn("makeAzureDevOpsPullRequestProvider")(function* () { + const azure = yield* AzureDevOpsCli; + + // `--detect true` reads the organization, project and repository from the + // checkout's own remote, which is the only place `az repos` learns all three. + const detectArgs = ["--detect", "true"] as const; + + const run = (input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => + azure + .execute({ + cwd: input.cwd, + args: [...input.args, "--only-show-errors", "--output", "json"], + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }) + .pipe(Effect.mapError((error) => toProviderError(input.operation, error))); + + const read = (input: { + readonly operation: string; + readonly subject: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly decode: (raw: string) => Result.Result>; + }) => + run(input).pipe( + Effect.flatMap((output) => { + const decoded = input.decode(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.operation, input.subject, decoded.failure)); + }), + ); + + const readViewer = (operation: string, cwd: string) => + run({ operation, cwd, args: ["account", "show", "--query", "user"] }).pipe( + Effect.flatMap((output) => { + // `--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 viewer. + const decoded = decodeAzureDevOpsViewerJson(`{"user":${output.stdout.trim() || "null"}}`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(operation, "account", decoded.failure)); + }), + ); + + const readPullRequest = ( + operation: string, + input: { readonly cwd: string; readonly number: number }, + ) => + read({ + operation, + subject: "pull request", + cwd: input.cwd, + args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], + decode: decodeAzureDevOpsPullRequestJson, + }).pipe( + Effect.flatMap((row) => + row === null + ? Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: "Azure DevOps said too little about this pull request to show it.", + }), + ) + : Effect.succeed(row), + ), + ); + + const toChangeRequest = (row: AzureDevOpsPullRequestRow): ProviderChangeRequest => ({ + number: row.number, + title: row.title, + url: row.url, + author: row.author, + headBranch: row.headBranch, + baseBranch: row.baseBranch, + state: row.state, + isDraft: row.isDraft, + // 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: row.createdAt, + updatedAt: row.updatedAt, + reviewRequestedLogins: row.reviewRequestedLogins, + // Azure keeps labels on work items rather than on the pull request. + labels: [], + }); + + const provider: PullRequestProviderApi = { + kind: PROVIDER_KIND, + capabilities: AZURE_DEVOPS_PULL_REQUEST_CAPABILITIES, + + getViewer: (input) => readViewer("getViewer", input.cwd), + + listChangeRequests: (input) => + read({ + operation: "list", + subject: "PR list", + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--repository", + repositoryName(input.repository), + ...statusArgs(input.state), + // A web link per row, which is the only url that needs no assembling. + "--include-links", + "--top", + String(input.limit), + ], + decode: decodeAzureDevOpsPullRequestListJson, + }).pipe(Effect.map((rows) => rows.map(toChangeRequest))), + + getChangeRequest: (input) => + readPullRequest("detail", input).pipe( + Effect.map((row) => ({ + ...toChangeRequest(row), + body: row.body, + // Azure reports the files a pull request touches only through a + // separate iteration read, which is not worth a request for a count. + changedFiles: 0, + mergeability: row.mergeability, + mergedAt: row.state === "merged" ? row.closedAt : null, + closedAt: row.state === "closed" ? row.closedAt : null, + reviewers: row.reviewers, + // Azure keeps its build results on branch policies rather than on the + // pull request, which `az repos pr` does not reach. + checks: [], + baseComparison: "unknown" as const, + behindBy: null, + autoMergeEnabled: row.autoMergeEnabled, + })), + ), + + getChangeRequestActivity: (input) => + Effect.gen(function* () { + const [row, viewer] = yield* Effect.all( + [ + readPullRequest("activity", input), + readViewer("activity", input.cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ), + ], + { concurrency: 2 }, + ); + // A pull request Azure said too little about carries no thread + // collection to read, which leaves the conversation empty rather than + // failing the read. + const comments = + row.threadsUrl === null + ? [] + : yield* read({ + operation: "activity", + subject: "threads", + cwd: input.cwd, + args: [ + "rest", + "--method", + "get", + "--url", + `${row.threadsUrl}?api-version=${REST_API_VERSION}`, + ], + decode: (raw) => decodeAzureDevOpsThreadsJson(raw, viewer), + }).pipe(Effect.catch(() => Effect.succeed([]))); + + return { + comments, + // Azure lists a pull request's commits behind an iteration read, which + // `az repos pr` does not reach. + commits: [], + reviewThreads: [], + reactions: [], + } satisfies PullRequestActivity; + }), + + // Never called: `capabilities.diff` is false, and the service refuses a diff + // without it. These exist because every provider answers the whole port. + getDiff: () => + unsupported("getDiff", "Azure DevOps cannot produce a patch for a pull request."), + + runAction: (input) => + run({ + operation: "runAction", + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + ...actionArgs(input.action, input.mergeMethod), + ], + ...(input.action === "merge" ? { timeoutMs: MERGE_TIMEOUT_MS } : {}), + }).pipe(Effect.asVoid), + + comment: () => unsupported("comment", "Azure DevOps comments cannot be written from here yet."), + + submitReview: () => + unsupported("submitReview", "Azure DevOps reviews cannot be written from here yet."), + + replyToThread: () => + unsupported("replyToThread", "Azure DevOps reviews cannot be written from here yet."), + + setThreadResolution: () => + unsupported("setThreadResolution", "Azure DevOps reviews cannot be written from here yet."), + + setReaction: () => unsupported("setReaction", "Azure DevOps does not support reactions."), + + updateChangeRequest: (input) => + run({ + operation: "update", + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + // One argument rather than a flag and a value beside it: a description + // usually opens with a bullet, and `az` reads a dash in the next argv + // slot as a flag of its own. + ...(input.title === undefined ? [] : [`--title=${input.title}`]), + ...(input.body === undefined ? [] : [`--description=${input.body}`]), + ], + }).pipe(Effect.asVoid), + + updateComment: () => + unsupported("updateComment", "Azure DevOps comments cannot be written from here yet."), + + listReviewerCandidates: () => + unsupported( + "listReviewerCandidates", + "Azure DevOps cannot say who may review a pull request.", + ), + + setReviewerRequest: (input) => { + const reviewers = input.reviewers.map((reviewer) => reviewer.id); + return reviewers.some((reviewer) => !isReviewerName(reviewer)) + ? unsupported( + "requestReviewers", + "Azure DevOps takes a reviewer's email address, display name or id.", + ) + : run({ + operation: "requestReviewers", + 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. + "--reviewers", + ...reviewers, + ], + }).pipe(Effect.asVoid); + }, + + /** + * Azure states no permission anywhere `az repos pr` reaches: the answer + * lives in the security namespaces, behind identity descriptors and token + * paths that would be several calls per pull request. So write access is + * granted and Azure refuses at the moment somebody tries, which is the safer + * half of an unknown: hiding a control from whoever is entitled to it leaves + * them no way through and no reason given. + */ + getRepositoryAccess: (input) => + read({ + operation: "repository", + subject: "repository", + cwd: input.cwd, + args: ["repos", "show", ...detectArgs, "--repository", repositoryName(input.repository)], + decode: decodeAzureDevOpsRepositoryJson, + }).pipe( + Effect.map((defaultBranch) => ({ + canWrite: true, + mergeMethods: AZURE_DEVOPS_PULL_REQUEST_CAPABILITIES.mergeMethods, + defaultBranch, + })), + ), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts new file mode 100644 index 000000000..1f5f4c259 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts @@ -0,0 +1,187 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, afterEach, describe, 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 BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; + +const mockRequest = vi.fn(); + +const layer = Layer.mock(BitbucketApi.BitbucketApi)({ request: mockRequest }); + +const repository = { cwd: "/workspaces/tools", repository: "acme/tools" }; +const pullRequestPath = "/repositories/acme/tools/pullrequests/7"; + +const calls = () => mockRequest.mock.calls.map(([input]) => input); + +const answer = (body: string) => Effect.succeed({ status: 200, body }); + +afterEach(() => { + mockRequest.mockReset(); +}); + +describe("BitbucketPullRequestProvider.listChangeRequests", () => { + it.effect("asks for both closed states at once, since Bitbucket separates them", () => + Effect.gen(function* () { + mockRequest.mockReturnValue(answer(JSON.stringify({ values: [] }))); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.listChangeRequests({ ...repository, state: "closed", limit: 30 }); + + assert.equal( + calls()[0]?.path, + "/repositories/acme/tools/pullrequests?state=DECLINED&state=SUPERSEDED&pagelen=30&sort=-updated_on&fields=%2Bvalues.reviewers", + ); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("BitbucketPullRequestProvider.runAction", () => { + it.effect("merges with the strategy Bitbucket names it by", () => + Effect.gen(function* () { + mockRequest.mockReturnValue(answer("")); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.runAction({ + ...repository, + number: 7, + action: "merge", + mergeMethod: "rebase", + }); + + assert.deepStrictEqual(calls()[0], { + method: "POST", + path: `${pullRequestPath}/merge`, + body: JSON.stringify({ merge_strategy: "rebase_fast_forward" }), + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("closes a pull request by declining it", () => + Effect.gen(function* () { + mockRequest.mockReturnValue(answer("")); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.runAction({ ...repository, number: 7, action: "close" }); + + assert.deepStrictEqual(calls()[0], { + method: "POST", + path: `${pullRequestPath}/decline`, + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("BitbucketPullRequestProvider.submitReview", () => { + it.effect("posts the line comments, then the summary, then the refusal", () => + Effect.gen(function* () { + mockRequest.mockReturnValue(answer("")); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.submitReview({ + ...repository, + number: 7, + verdict: "request-changes", + body: "Please split this.", + comments: [ + { path: "a.ts", position: { kind: "added", newLine: 12 }, body: "New line" }, + { path: "b.ts", position: { kind: "deleted", oldLine: 7 }, body: "Old line" }, + ], + }); + + assert.deepStrictEqual(calls(), [ + { + method: "POST", + path: `${pullRequestPath}/comments`, + body: JSON.stringify({ content: { raw: "New line" }, inline: { path: "a.ts", to: 12 } }), + }, + { + method: "POST", + path: `${pullRequestPath}/comments`, + body: JSON.stringify({ content: { raw: "Old line" }, inline: { path: "b.ts", from: 7 } }), + }, + { + method: "POST", + path: `${pullRequestPath}/comments`, + body: JSON.stringify({ content: { raw: "Please split this." } }), + }, + { method: "POST", path: `${pullRequestPath}/request-changes` }, + ]); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("BitbucketPullRequestProvider.setThreadResolution", () => { + it.effect("creates and deletes the resolution, which Bitbucket keeps as a sub-resource", () => + Effect.gen(function* () { + mockRequest.mockReturnValue(answer("")); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.setThreadResolution({ + ...repository, + number: 7, + threadId: "42", + resolved: false, + }); + + assert.deepStrictEqual(calls()[0], { + method: "DELETE", + path: `${pullRequestPath}/comments/42/resolve`, + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("BitbucketPullRequestProvider.setReviewerRequest", () => { + it.effect("writes the whole reviewer set back, since Bitbucket replaces rather than adds", () => + Effect.gen(function* () { + mockRequest.mockImplementation((input) => + answer( + input.method === "GET" + ? JSON.stringify({ + id: 7, + title: "Tidy the toolbox", + state: "OPEN", + source: { branch: { name: "feature/tidy" } }, + destination: { branch: { name: "main" } }, + created_on: "2026-08-30T10:00:00+00:00", + updated_on: "2026-08-31T10:00:00+00:00", + links: { html: { href: "https://bitbucket.org/acme/tools/pull-requests/7" } }, + reviewers: [{ uuid: "{abc}", nickname: "hubot" }], + }) + : "", + ), + ); + const provider = yield* BitbucketPullRequestProvider.make(); + + yield* provider.setReviewerRequest({ + ...repository, + number: 7, + reviewers: [{ id: "{def}", kind: "user" }], + requested: true, + }); + + assert.deepStrictEqual(calls().at(-1), { + method: "PUT", + path: pullRequestPath, + body: JSON.stringify({ reviewers: [{ uuid: "{abc}" }, { uuid: "{def}" }] }), + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("BitbucketPullRequestProvider.setReaction", () => { + it.effect("refuses without writing, Bitbucket having no reaction to set", () => + Effect.gen(function* () { + const provider = yield* BitbucketPullRequestProvider.make(); + + const error = yield* provider + .setReaction({ ...repository, number: 7, content: "thumbs-up", reacted: true }) + .pipe(Effect.flip); + + assert.equal(error.detail, "Bitbucket does not support reactions."); + assert.deepStrictEqual(calls(), []); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts new file mode 100644 index 000000000..cca651554 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -0,0 +1,612 @@ +import type * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import type * as Schema from "effect/Schema"; + +import type { + PullRequestActivity, + PullRequestCapabilities, + PullRequestListState, + PullRequestReviewer, +} from "@threadlines/contracts"; +import { formatSchemaError } from "@threadlines/shared/schemaJson"; + +import type * as BitbucketApiModule from "../sourceControl/BitbucketApi.ts"; +import { BitbucketApi } from "../sourceControl/BitbucketApi.ts"; +import { + buildBitbucketCommentJson, + buildBitbucketInlineCommentJson, + buildBitbucketMergeJson, + buildBitbucketPullRequestUpdateJson, + buildBitbucketReplyJson, + buildBitbucketReviewersJson, + buildBitbucketReviewThreads, + decodeBitbucketCommentsJson, + decodeBitbucketCommitsJson, + decodeBitbucketConflictsJson, + decodeBitbucketDiffStatJson, + decodeBitbucketPullRequestJson, + decodeBitbucketPullRequestPageJson, + decodeBitbucketRepositoryJson, + decodeBitbucketRepositoryPermissionJson, + decodeBitbucketStatusesJson, + decodeBitbucketViewerJson, + decodeBitbucketWorkspaceMembersJson, + type BitbucketPullRequestRow, +} from "./bitbucketPullRequest.ts"; +import { capPullRequestDiff } from "./pullRequestDiff.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const PROVIDER_KIND = "bitbucket" as const; +/** Bitbucket's own ceiling on `pagelen`. */ +const MAX_PAGE_SIZE = 50; +/** Bitbucket's permission endpoint was withdrawn and now answers this to everyone. */ +const PERMISSION_ENDPOINT_REMOVED_STATUS = 410; + +/** + * Everything Bitbucket lets a reader do here. Bitbucket publishes no + * per-repository list of allowed strategies, so all three are offered and one + * the repository forbids fails at the merge. + */ +export const BITBUCKET_PULL_REQUEST_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"], + updateMethods: [], + // Bitbucket Cloud's API exposes no reaction on a pull request or a comment. + reactions: false, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { pullRequest: true, comment: true }, +}; + +/** The failures that mean the credentials are the problem, not one request. */ +export function classifyBitbucketFailure( + error: BitbucketApiModule.BitbucketApiError, +): PullRequestProviderError["reason"] { + // Bitbucket is read over HTTP with credentials from the server's environment, + // so there is no tool to be missing: unusable means absent or refused + // credentials. + if (error.status === 401 || error.status === 403) { + return "unauthenticated"; + } + if (error.status === 429) { + return "rate-limited"; + } + return "failed"; +} + +function toProviderError(operation: string, error: BitbucketApiModule.BitbucketApiError) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: classifyBitbucketFailure(error), + detail: error.detail, + }); +} + +function decodeError(operation: string, subject: string, failure: Cause.Cause) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: `Bitbucket returned invalid ${subject} JSON: ${formatSchemaError(failure)}`, + }); +} + +/** + * Bitbucket unions repeated `state` parameters, so a slice 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"]; + } +} + +function toChangeRequest(row: BitbucketPullRequestRow): ProviderChangeRequest { + return { + number: row.number, + title: row.title, + url: row.url, + author: row.author, + headBranch: row.headBranch, + baseBranch: row.baseBranch, + state: row.state, + isDraft: row.isDraft, + // Line counts are a read of their own, worth spending only on the detail. + additions: 0, + deletions: 0, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + reviewRequestedLogins: row.reviewRequestedLogins, + // Bitbucket has no labels on a pull request. + labels: [], + }; +} + +export const make = Effect.fn("makeBitbucketPullRequestProvider")(function* () { + const bitbucket = yield* BitbucketApi; + + const request = (input: { + readonly operation: string; + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + readonly path: string; + readonly body?: string; + }) => + bitbucket + .request({ + method: input.method, + path: input.path, + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError((error) => toProviderError(input.operation, error))); + + const read = (input: { + readonly operation: string; + readonly subject: string; + readonly path: string; + readonly decode: (raw: string) => Result.Result>; + }) => + request({ operation: input.operation, method: "GET", path: input.path }).pipe( + Effect.flatMap((response) => { + const decoded = input.decode(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.operation, input.subject, decoded.failure)); + }), + ); + + /** `workspace/slug`; Bitbucket has no deeper nesting to address. */ + const repositoryPath = (operation: string, repository: string) => { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + const [workspace, slug] = segments; + return segments.length === 2 && workspace !== undefined && slug !== undefined + ? Effect.succeed({ + path: `/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent(slug)}`, + workspace, + }) + : Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: "Bitbucket repositories are addressed as workspace/repository.", + }), + ); + }; + + const withRepository = ( + operation: string, + repository: string, + use: (input: { + readonly path: string; + readonly workspace: string; + }) => Effect.Effect, + ) => repositoryPath(operation, repository).pipe(Effect.flatMap(use)); + + const pullRequestPath = (path: string, number: number) => `${path}/pullrequests/${number}`; + + const readViewer = (operation: string) => + read({ operation, subject: "user", path: "/user", decode: decodeBitbucketViewerJson }); + + const readPullRequest = ( + operation: string, + input: ProviderRepositoryRef & { readonly number: number }, + ) => + withRepository(operation, input.repository, ({ path }) => + read({ + operation, + subject: "pull request", + path: pullRequestPath(path, input.number), + decode: decodeBitbucketPullRequestJson, + }), + ); + + /** + * Whether the credentials may write. Bitbucket withdrew this endpoint + * (CHANGE-2770) and now answers HTTP 410 to every account, whatever it may + * do — that is a deprecated endpoint rather than a permission being refused, + * so it reads as a standing that could not be learned, which grants and + * leaves the merge itself to say why if the account may not do it. + */ + const readCanWrite = (operation: string, repository: string) => + read({ + operation, + subject: "permissions", + path: `/user/permissions/repositories?q=${encodeURIComponent( + `repository.full_name="${repository.trim().replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`, + )}`, + decode: decodeBitbucketRepositoryPermissionJson, + }).pipe( + Effect.catchIf( + (error) => error.detail.includes(`HTTP ${PERMISSION_ENDPOINT_REMOVED_STATUS}`), + () => Effect.succeed(true), + ), + ); + + const provider: PullRequestProviderApi = { + kind: PROVIDER_KIND, + capabilities: BITBUCKET_PULL_REQUEST_CAPABILITIES, + + // Bitbucket credentials come from the server's environment rather than a + // checkout, so the account is the same whichever workspace asks. + getViewer: () => readViewer("getViewer"), + + listChangeRequests: (input) => + withRepository("list", input.repository, ({ path }) => + read({ + operation: "list", + subject: "PR list", + // Reviewers are left off a listing by default, and the viewer's own + // review request is worked out from them. + path: `${path}/pullrequests?${stateParams(input.state) + .map((state) => `state=${state}`) + .join( + "&", + )}&pagelen=${Math.min(input.limit, MAX_PAGE_SIZE)}&sort=-updated_on&fields=%2Bvalues.reviewers`, + decode: decodeBitbucketPullRequestPageJson, + }).pipe(Effect.map((page) => page.items.map(toChangeRequest))), + ), + + getChangeRequest: (input) => + withRepository("detail", input.repository, ({ path }) => { + const target = pullRequestPath(path, input.number); + return Effect.all( + [ + read({ + operation: "detail", + subject: "pull request", + path: target, + decode: decodeBitbucketPullRequestJson, + }), + read({ + operation: "detail", + subject: "diffstat", + path: `${target}/diffstat?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeBitbucketDiffStatJson, + }), + // Bitbucket reports no conflict state on a pull request itself, and + // an unreadable conflicts endpoint leaves it unknown rather than + // failing a detail the reader can still use. + read({ + operation: "detail", + subject: "conflicts", + path: `${target}/conflicts`, + decode: decodeBitbucketConflictsJson, + }).pipe(Effect.catch(() => Effect.succeed("unknown" as const))), + read({ + operation: "detail", + subject: "statuses", + path: `${target}/statuses?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeBitbucketStatusesJson, + }).pipe(Effect.catch(() => Effect.succeed({ items: [], next: null }))), + ], + { concurrency: 4 }, + ).pipe( + Effect.map(([row, diffStat, mergeability, checks]) => ({ + ...toChangeRequest(row), + additions: diffStat.additions, + deletions: diffStat.deletions, + changedFiles: diffStat.changedFiles, + body: row.body, + mergeability, + // Bitbucket stamps no separate merged or closed time; the pull + // request's last update is when it settled. + mergedAt: row.state === "merged" ? row.updatedAt : null, + closedAt: row.state === "closed" ? row.updatedAt : null, + reviewers: row.reviewers.map((reviewer): PullRequestReviewer => ({ + id: reviewer.id, + kind: "user", + login: reviewer.login, + state: + row.reviews.find( + (review) => review.author?.login.toLowerCase() === reviewer.login.toLowerCase(), + )?.reviewState ?? "pending", + })), + checks: checks.items, + // Bitbucket compares no branch with its base. + baseComparison: "unknown" as const, + behindBy: null, + // Bitbucket has nothing that arms a merge to run on its own. + autoMergeEnabled: null, + })), + ); + }), + + getChangeRequestActivity: (input) => + Effect.gen(function* () { + // Bitbucket names who wrote a remark but never whether that is the + // reader, so the account is read once and the comparison made here. + const viewer = yield* readViewer("activity").pipe( + Effect.catch(() => Effect.succeed(null)), + ); + return yield* withRepository("activity", input.repository, ({ path }) => { + const target = pullRequestPath(path, input.number); + return Effect.all( + [ + // The verdicts ride on the pull request itself. + read({ + operation: "activity", + subject: "pull request", + path: target, + decode: decodeBitbucketPullRequestJson, + }), + read({ + operation: "activity", + subject: "comments", + path: `${target}/comments?pagelen=${MAX_PAGE_SIZE}`, + decode: (raw) => decodeBitbucketCommentsJson(raw, viewer), + }), + read({ + operation: "activity", + subject: "commits", + path: `${target}/commits?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeBitbucketCommitsJson, + }), + ], + { concurrency: 3 }, + ).pipe( + Effect.map(([row, comments, commits]): PullRequestActivity => ({ + comments: [...comments.comments, ...row.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits: commits.items, + reviewThreads: buildBitbucketReviewThreads(comments.entries, viewer), + reactions: [], + })), + ); + }); + }), + + // `/diff` answers with the whole patch and pages nothing. + getDiff: (input) => + withRepository("diff", input.repository, ({ path }) => + request({ + operation: "diff", + method: "GET", + path: `${pullRequestPath(path, input.number)}/diff`, + }).pipe( + Effect.map((response) => capPullRequestDiff({ patch: response.body, truncated: false })), + ), + ), + + runAction: (input) => + withRepository("runAction", input.repository, ({ path }) => { + const target = pullRequestPath(path, input.number); + // Only merge and close reach here: the provider declares nothing else, + // and the service refuses an action the capabilities do not carry. + return input.action === "merge" + ? request({ + operation: "runAction", + method: "POST", + path: `${target}/merge`, + body: buildBitbucketMergeJson(input.mergeMethod), + }).pipe(Effect.asVoid) + : request({ + operation: "runAction", + method: "POST", + path: `${target}/decline`, + }).pipe(Effect.asVoid); + }), + + comment: (input) => + withRepository("comment", input.repository, ({ path }) => + request({ + operation: "comment", + method: "POST", + path: `${pullRequestPath(path, input.number)}/comments`, + body: buildBitbucketCommentJson(input.body), + }).pipe(Effect.as({ url: null })), + ), + + submitReview: (input) => + withRepository("submitReview", input.repository, ({ path }) => + Effect.gen(function* () { + const target = pullRequestPath(path, 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) => + request({ + operation: "submitReview", + method: "POST", + path: `${target}/comments`, + body: buildBitbucketInlineCommentJson(comment), + }), + { discard: true }, + ); + if (input.body.trim().length > 0) { + yield* request({ + operation: "submitReview", + method: "POST", + path: `${target}/comments`, + body: buildBitbucketCommentJson(input.body), + }); + } + if (input.verdict === "approve") { + yield* request({ + operation: "submitReview", + method: "POST", + path: `${target}/approve`, + }); + } + if (input.verdict === "request-changes") { + yield* request({ + operation: "submitReview", + method: "POST", + path: `${target}/request-changes`, + }); + } + return { url: null }; + }), + ), + + replyToThread: (input) => + withRepository("replyToThread", input.repository, ({ path }) => + request({ + operation: "replyToThread", + method: "POST", + path: `${pullRequestPath(path, input.number)}/comments`, + body: buildBitbucketReplyJson({ parentId: input.threadId, body: input.body }), + }).pipe(Effect.asVoid), + ), + + setThreadResolution: (input) => + withRepository("setThreadResolution", input.repository, ({ path }) => + request({ + operation: "setThreadResolution", + // Resolving is a sub-resource that is created and deleted, not a field. + method: input.resolved ? "POST" : "DELETE", + path: `${pullRequestPath(path, input.number)}/comments/${encodeURIComponent( + input.threadId, + )}/resolve`, + }).pipe(Effect.asVoid), + ), + + // Never called: `capabilities.reactions` is false, and the service refuses + // without it. It exists because every provider answers the whole port. + setReaction: () => + Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation: "setReaction", + reason: "failed", + detail: "Bitbucket does not support reactions.", + }), + ), + + updateChangeRequest: (input) => + withRepository("update", input.repository, ({ path }) => + request({ + operation: "update", + method: "PUT", + path: pullRequestPath(path, input.number), + body: buildBitbucketPullRequestUpdateJson({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }), + }).pipe(Effect.asVoid), + ), + + // The kind is not read: Bitbucket keeps a pull request's remarks and its + // line comments in one collection, and this endpoint rewrites either. + updateComment: (input) => + withRepository("updateComment", input.repository, ({ path }) => + request({ + operation: "updateComment", + method: "PUT", + path: `${pullRequestPath(path, input.number)}/comments/${encodeURIComponent( + input.commentId, + )}`, + body: buildBitbucketCommentJson(input.body), + }).pipe(Effect.asVoid), + ), + + // Users only: Bitbucket asks an account for a review, and has no group that + // stands in for one on a pull request. + listReviewerCandidates: (input) => + withRepository("reviewerCandidates", input.repository, ({ path, workspace }) => + Effect.all( + [ + read({ + operation: "reviewerCandidates", + subject: "pull request", + path: pullRequestPath(path, input.number), + decode: decodeBitbucketPullRequestJson, + }), + read({ + operation: "reviewerCandidates", + subject: "workspace members", + path: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeBitbucketWorkspaceMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([row, members]) => { + const requested = new Set(row.reviewers.map((reviewer) => reviewer.id)); + const author = row.author?.login.toLowerCase() ?? null; + return { + // The author is dropped rather than shown unusable: Bitbucket + // refuses to make whoever opened a pull request its reviewer. + candidates: members.items.flatMap((candidate) => + candidate.login.toLowerCase() === author + ? [] + : [{ ...candidate, requested: requested.has(candidate.id) }], + ), + }; + }), + ), + ), + + setReviewerRequest: (input) => + withRepository("requestReviewers", input.repository, ({ path }) => + readPullRequest("requestReviewers", input).pipe( + Effect.flatMap((row) => + request({ + operation: "requestReviewers", + method: "PUT", + path: pullRequestPath(path, input.number), + body: buildBitbucketReviewersJson({ + current: row.reviewers.map((reviewer) => reviewer.id), + reviewers: input.reviewers, + requested: input.requested, + }), + }), + ), + Effect.asVoid, + ), + ), + + getRepositoryAccess: (input) => + withRepository("repository", input.repository, ({ path }) => + Effect.all( + [ + readCanWrite("repository", input.repository), + read({ + operation: "repository", + subject: "repository", + path, + decode: decodeBitbucketRepositoryJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([canWrite, defaultBranch]) => ({ + canWrite, + mergeMethods: BITBUCKET_PULL_REQUEST_CAPABILITIES.mergeMethods, + defaultBranch, + })), + ), + ), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts new file mode 100644 index 000000000..3ec458ec6 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -0,0 +1,221 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockExecute = vi.fn(); + +const layer = Layer.mergeAll( + Layer.mock(GitHubCli.GitHubCli)({ execute: mockExecute }), + NodeServices.layer, +); + +const repository = { cwd: "/workspaces/example-app", repository: "octocat/example-app" }; + +const calls = () => mockExecute.mock.calls.map(([input]) => input); + +afterEach(() => { + mockExecute.mockReset(); +}); + +describe("GitHubPullRequestProvider.runAction", () => { + const cases = [ + { + name: "rebases a branch onto its base", + input: { action: "update-branch", updateMethod: "rebase" }, + args: ["pr", "update-branch", "12", "--repo", "octocat/example-app", "--rebase"], + }, + { + name: "arms auto-merge with the strategy the host stores alongside it", + input: { action: "enable-auto-merge", mergeMethod: "squash" }, + args: ["pr", "merge", "12", "--repo", "octocat/example-app", "--auto", "--squash"], + }, + { + name: "disarms auto-merge", + input: { action: "disable-auto-merge" }, + args: ["pr", "merge", "12", "--repo", "octocat/example-app", "--disable-auto"], + }, + { + name: "deletes the head branch after a merge when asked", + input: { action: "merge", mergeMethod: "merge", deleteBranch: true }, + args: ["pr", "merge", "12", "--repo", "octocat/example-app", "--merge", "--delete-branch"], + }, + ] as const; + + for (const testCase of cases) { + it.effect(testCase.name, () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitHubPullRequestProvider.make(); + + yield* provider.runAction({ ...repository, number: 12, ...testCase.input }); + + assert.deepStrictEqual(calls()[0]?.args, testCase.args); + }).pipe(Effect.provide(layer)), + ); + } +}); + +describe("GitHubPullRequestProvider.submitReview", () => { + it.effect("sends every line comment on the side its position names", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitHubPullRequestProvider.make(); + + yield* provider.submitReview({ + ...repository, + number: 12, + verdict: "request-changes", + body: "Two notes.", + comments: [ + { path: "a.ts", position: { kind: "added", newLine: 12 }, body: "New line" }, + { path: "b.ts", position: { kind: "deleted", oldLine: 7 }, body: "Old line" }, + { + path: "c.ts", + position: { kind: "context", oldLine: 3, newLine: 4, side: "left" }, + body: "Context on the left", + }, + { + path: "d.ts", + position: { kind: "context", oldLine: 5, newLine: 6, side: "right" }, + body: "Context on the right", + }, + ], + }); + + const call = calls()[0]; + assert.deepStrictEqual(call?.args, [ + "api", + "--method", + "POST", + "repos/octocat/example-app/pulls/12/reviews", + "--input", + "-", + ]); + assert.deepStrictEqual(JSON.parse(call?.stdin ?? "{}"), { + event: "REQUEST_CHANGES", + body: "Two notes.", + comments: [ + { path: "a.ts", line: 12, side: "RIGHT", body: "New line" }, + { path: "b.ts", line: 7, side: "LEFT", body: "Old line" }, + { path: "c.ts", line: 3, side: "LEFT", body: "Context on the left" }, + { path: "d.ts", line: 6, side: "RIGHT", body: "Context on the right" }, + ], + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("GitHubPullRequestProvider.setReaction", () => { + it.effect("refuses a subject that belongs to another pull request without mutating", () => + Effect.gen(function* () { + mockExecute.mockReturnValue( + Effect.succeed( + processOutput( + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_here" } }, + node: { id: "IC_1", pullRequest: { id: "PR_elsewhere" } }, + }, + }), + ), + ), + ); + const provider = yield* GitHubPullRequestProvider.make(); + + const error = yield* provider + .setReaction({ + ...repository, + number: 12, + subjectId: "IC_1", + content: "thumbs-up", + reacted: true, + }) + .pipe(Effect.flip); + + assert.equal(error.detail, "That comment does not belong to this pull request."); + // Only the scope read ran; the mutation never reached the host. + expect(calls()).toHaveLength(1); + assert.equal(calls()[0]?.stdin?.includes("addReaction"), false); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("GitHubPullRequestProvider.setReviewerRequest", () => { + it.effect("splits people from teams and takes a request back with DELETE", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitHubPullRequestProvider.make(); + + yield* provider.setReviewerRequest({ + ...repository, + number: 12, + reviewers: [ + { id: "hubot", kind: "user" }, + { id: "core", kind: "team" }, + { id: "monalisa", kind: "user" }, + ], + requested: false, + }); + + const call = calls()[0]; + assert.deepStrictEqual(call?.args, [ + "api", + "--method", + "DELETE", + "repos/octocat/example-app/pulls/12/requested_reviewers", + "--input", + "-", + ]); + assert.deepStrictEqual(JSON.parse(call?.stdin ?? "{}"), { + reviewers: ["hubot", "monalisa"], + team_reviewers: ["core"], + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("GitHubPullRequestProvider.listAuthoredChangeRequests", () => { + it.effect("searches the whole host for the viewer's own work, on stdin", () => + Effect.gen(function* () { + mockExecute.mockReturnValue( + Effect.succeed(processOutput(JSON.stringify({ data: { search: { nodes: [] } } }))), + ); + const provider = yield* GitHubPullRequestProvider.make(); + const search = provider.listAuthoredChangeRequests; + if (search === undefined) { + return assert.fail("GitHub can search for the viewer's own pull requests"); + } + + yield* search({ cwd: repository.cwd, viewer: "octocat", state: "closed", limit: 30 }); + + const call = calls()[0]; + assert.deepStrictEqual(call?.args, ["api", "graphql", "--input", "-"]); + const body = JSON.parse(call?.stdin ?? "{}") as { + query: string; + variables: Record; + }; + // GitHub counts a merged pull request as closed as well, so the closed + // slice has to ask for the unmerged half of that. + assert.deepStrictEqual(body.variables, { + q: "is:pr author:octocat is:closed is:unmerged", + first: 30, + }); + assert.equal(body.query.includes("search(query: $q, type: ISSUE, first: $first)"), true); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts new file mode 100644 index 000000000..497002ae6 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -0,0 +1,723 @@ +import type * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Result from "effect/Result"; +import type * as Schema from "effect/Schema"; + +import type { + PullRequestAction, + PullRequestActivity, + PullRequestBaseComparison, + PullRequestCapabilities, + PullRequestComment, + PullRequestListState, + PullRequestMergeMethod, + PullRequestUpdateMethod, +} from "@threadlines/contracts"; +import { formatSchemaError } from "@threadlines/shared/schemaJson"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, +} from "../sourceControl/gitHubAuthStatus.ts"; +import { + decodeGitHubPullRequestActivityJson, + decodeGitHubPullRequestDetailJson, + decodeGitHubRepositoryJson, + GITHUB_PULL_REQUEST_ACTIVITY_FIELDS, + GITHUB_PULL_REQUEST_DETAIL_FIELDS, +} from "./gitHubPullRequestDetail.ts"; +import { + ADD_REACTION_GRAPHQL_MUTATION, + AUTHORED_PULL_REQUESTS_GRAPHQL_QUERY, + BASE_COMPARISON_GRAPHQL_QUERY, + buildGitHubReviewerRequestJson, + buildGitHubReviewSubmissionJson, + decodeGitHubAuthoredPullRequestsJson, + decodeGitHubBaseComparisonJson, + decodeGitHubPullRequestConversationJson, + decodeGitHubPullRequestNodeIdJson, + decodeGitHubReviewerCandidatesJson, + decodeGitHubSubjectScopeJson, + encodeGraphQlRequestJson, + gitHubAuthoredSearchQuery, + gitHubReactionContent, + PULL_REQUEST_CONVERSATION_GRAPHQL_QUERY, + PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + REACTION_SUBJECT_SCOPE_GRAPHQL_QUERY, + REMOVE_REACTION_GRAPHQL_MUTATION, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, + UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, +} from "./gitHubPullRequestGraphql.ts"; +import { + decodeGitHubPullRequestListJson, + GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD, + GITHUB_PULL_REQUEST_LIST_FIELDS, +} from "./gitHubPullRequestList.ts"; +import { capPullRequestDiff, PULL_REQUEST_DIFF_MAX_BYTES } from "./pullRequestDiff.ts"; +import { + PullRequestProviderError, + type PullRequestProviderApi, + type ProviderRepositoryRef, +} from "./PullRequestProvider.ts"; + +const PROVIDER_KIND = "github" as const; +const DIFF_TIMEOUT_MS = 60_000; +/** A merge waits on the host settling its checks and its queue. */ +const MERGE_TIMEOUT_MS = 60_000; + +/** Everything GitHub lets a reader do here. The repository narrows `mergeMethods`. */ +export const GITHUB_PULL_REQUEST_CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "close", + "reopen", + "ready", + "draft", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], + reactions: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { pullRequest: true, comment: true }, +}; + +/** Turns a `gh` failure into the reason the page renders an action for. */ +export function classifyGitHubFailure(detail: string): PullRequestProviderError["reason"] { + const lower = detail.toLowerCase(); + if ( + lower.includes("not available on path") || + lower.includes("command not found") || + lower.includes("enoent") + ) { + return "missing-tool"; + } + if ( + lower.includes("not logged in") || + lower.includes("not authenticated") || + lower.includes("authentication") || + lower.includes("auth login") + ) { + return "unauthenticated"; + } + if (lower.includes("rate limit")) { + return "rate-limited"; + } + return "failed"; +} + +/** + * The line of a `gh` failure worth showing. The CLI stacks its own wrapper + * around the host's complaint, and only the last line of that stack is the host + * talking; a single-line failure is already that line. + */ +function lastFailureLine(detail: string): string { + const lines = detail + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return lines.length > 1 ? (lines[lines.length - 1] ?? detail.trim()) : detail.trim(); +} + +function toProviderError(operation: string, error: GitHubCli.GitHubCliError) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: classifyGitHubFailure(error.detail), + detail: lastFailureLine(error.detail), + }); +} + +function decodeError(operation: string, subject: string, failure: Cause.Cause) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: `GitHub CLI returned invalid ${subject} JSON: ${formatSchemaError(failure)}`, + }); +} + +/** `gh pr comment` and `gh pr review` print the new URL; nothing else is on stdout. */ +function parseResultUrl(stdout: string): string | null { + const match = stdout.match(/https?:\/\/\S+/); + return match === null ? null : match[0]; +} + +/** `owner/name` split for the endpoints and GraphQL variables that need the halves. */ +function repositoryParts(repository: string): { readonly owner: string; readonly name: string } { + const [owner = "", name = ""] = repository.trim().split("/"); + return { owner, name }; +} + +function listFieldsFor(state: PullRequestListState): string { + return state === "open" + ? [...GITHUB_PULL_REQUEST_LIST_FIELDS, GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD].join(",") + : GITHUB_PULL_REQUEST_LIST_FIELDS.join(","); +} + +function actionArgs(input: { + readonly action: PullRequestAction; + readonly mergeMethod: PullRequestMergeMethod | undefined; + readonly updateMethod: PullRequestUpdateMethod | undefined; + readonly deleteBranch: boolean | undefined; +}): ReadonlyArray { + switch (input.action) { + case "merge": + return [ + "merge", + `--${input.mergeMethod ?? "merge"}`, + ...(input.deleteBranch === true ? ["--delete-branch"] : []), + ]; + // `--auto` arms the same command instead of running it, and still needs a + // strategy: GitHub stores one with the standing instruction. + case "enable-auto-merge": + return ["merge", "--auto", `--${input.mergeMethod ?? "merge"}`]; + case "disable-auto-merge": + return ["merge", "--disable-auto"]; + // `gh` updates with a merge commit unless asked to rebase, GitHub's own default. + case "update-branch": + return ["update-branch", ...(input.updateMethod === "rebase" ? ["--rebase"] : [])]; + case "ready": + return ["ready"]; + // Turning a pull request back into a draft is the host's `ready --undo`. + case "draft": + return ["ready", "--undo"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +/** Where the head branch stands, from the count of commits the base is ahead by. */ +function toBaseComparison(behindBy: number | null): PullRequestBaseComparison { + return behindBy === null ? "unknown" : behindBy > 0 ? "behind" : "up-to-date"; +} + +export const make = Effect.fn("makeGitHubPullRequestProvider")(function* () { + const github = yield* GitHubCli.GitHubCli; + const fileSystem = yield* FileSystem.FileSystem; + + const run = (input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + }) => + github + .execute({ + cwd: input.cwd, + args: input.args, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }) + .pipe(Effect.mapError((error) => toProviderError(input.operation, error))); + + /** + * A GraphQL request. The document and its variables travel together on stdin, + * so a reader's own words never reach argv, where they would show up in + * process listings and inside process-runner failure messages. + */ + const graphql = (input: { + readonly operation: string; + readonly cwd: string; + readonly query: string; + readonly variables: Readonly>; + }) => + run({ + operation: input.operation, + cwd: input.cwd, + args: ["api", "graphql", "--input", "-"], + stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), + }); + + const graphqlRead = (input: { + readonly operation: string; + readonly cwd: string; + readonly query: string; + readonly variables: Readonly>; + readonly decode: (raw: string) => Result.Result>; + }) => + graphql(input).pipe( + Effect.flatMap((output) => { + const decoded = input.decode(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.operation, "GraphQL", decoded.failure)); + }), + ); + + /** + * `gh` takes a body by path. In argv it would show up in process listings and + * run into the command length limit. + */ + const withBodyFile = (operation: string, body: string) => + fileSystem.makeTempFileScoped({ prefix: "threadlines-pr-body-", suffix: ".md" }).pipe( + Effect.tap((filePath) => fileSystem.writeFileString(filePath, body)), + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: `Failed to write the body to a temp file: ${cause.message}`, + }), + ), + ); + + const graphQlVariables = (input: ProviderRepositoryRef & { readonly number: number }) => { + const { owner, name } = repositoryParts(input.repository); + return { owner, name, number: input.number }; + }; + + /** + * The pull request's own node id, which is what a mutation against the pull + * request itself is addressed by. Read only when one is being written: the + * conversation carries an id for every remark in it, and the pull request is + * the one subject nothing in it names. + */ + const pullRequestNodeId = ( + operation: string, + input: ProviderRepositoryRef & { readonly number: number }, + ) => + graphqlRead({ + operation, + cwd: input.cwd, + query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + variables: graphQlVariables(input), + decode: decodeGitHubPullRequestNodeIdJson, + }).pipe( + Effect.flatMap((id) => + id === null + ? Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: "GitHub did not report an id for this pull request.", + }), + ) + : Effect.succeed(id), + ), + ); + + /** + * Whether a client-given subject really belongs to the pull request the + * request names. A subject id is trusted to be whatever node it names, and + * that node can hang off any pull request on the host, so the mutation would + * otherwise write wherever the id actually points. + */ + const requireSubjectInPullRequest = ( + operation: string, + input: ProviderRepositoryRef & { readonly number: number; readonly subjectId: string }, + ) => + graphqlRead({ + operation, + cwd: input.cwd, + query: REACTION_SUBJECT_SCOPE_GRAPHQL_QUERY, + variables: { ...graphQlVariables(input), subjectId: input.subjectId }, + decode: decodeGitHubSubjectScopeJson, + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.subjectId) + : Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: "That comment does not belong to this pull request.", + }), + ), + ), + ); + + const repositoryArgs = (input: ProviderRepositoryRef) => ["--repo", input.repository]; + + const provider: PullRequestProviderApi = { + kind: PROVIDER_KIND, + capabilities: GITHUB_PULL_REQUEST_CAPABILITIES, + + getViewer: (input) => + run({ + operation: "getViewer", + cwd: input.cwd, + args: ["auth", "status", "--json", "hosts"], + }).pipe( + Effect.map((output): string | null => { + const status = parseGitHubAuthStatus(output.stdout); + const account = findAuthenticatedGitHubAccount( + status.accounts.filter((entry) => entry.host === "github.com"), + ); + return account?.account ?? null; + }), + ), + + listChangeRequests: (input) => + run({ + operation: "list", + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input), + "--state", + input.state, + "--limit", + String(input.limit), + "--json", + listFieldsFor(input.state), + ], + }).pipe( + Effect.flatMap((output) => { + const raw = output.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError("list", "PR list", decoded.failure)); + }), + ), + + listAuthoredChangeRequests: (input) => + graphqlRead({ + operation: "listAuthored", + cwd: input.cwd, + query: AUTHORED_PULL_REQUESTS_GRAPHQL_QUERY, + variables: { + q: gitHubAuthoredSearchQuery({ viewer: input.viewer, state: input.state }), + first: input.limit, + }, + decode: decodeGitHubAuthoredPullRequestsJson, + }), + + getChangeRequest: (input) => + run({ + operation: "detail", + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + GITHUB_PULL_REQUEST_DETAIL_FIELDS.join(","), + ], + }).pipe( + Effect.flatMap((output) => { + const decoded = decodeGitHubPullRequestDetailJson(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError("detail", "pull request", decoded.failure)); + }), + Effect.flatMap((row) => + graphqlRead({ + operation: "detail", + cwd: input.cwd, + query: BASE_COMPARISON_GRAPHQL_QUERY, + variables: { + ...graphQlVariables(input), + headRef: + row.headRepositoryOwnerLogin === null + ? row.headBranch + : `${row.headRepositoryOwnerLogin}:${row.headBranch}`, + }, + decode: decodeGitHubBaseComparisonJson, + }).pipe( + // A comparison the host will not make leaves the branch's freshness + // unknown; it is not worth failing a detail the reader can use. + Effect.catch(() => Effect.succeed(null)), + Effect.map((behindBy) => ({ + ...row, + baseComparison: toBaseComparison(behindBy), + behindBy, + })), + ), + ), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + run({ + operation: "activity", + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + GITHUB_PULL_REQUEST_ACTIVITY_FIELDS.join(","), + ], + }).pipe( + Effect.flatMap((output) => { + const decoded = decodeGitHubPullRequestActivityJson(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError("activity", "activity", decoded.failure)); + }), + ), + graphqlRead({ + operation: "activity", + cwd: input.cwd, + query: PULL_REQUEST_CONVERSATION_GRAPHQL_QUERY, + variables: graphQlVariables(input), + decode: decodeGitHubPullRequestConversationJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([activity, conversation]): PullRequestActivity => ({ + comments: activity.comments.map((comment): PullRequestComment => { + const annotation = conversation.annotationsByCommentId.get(comment.id); + return annotation === undefined + ? comment + : { + ...comment, + reactions: annotation.reactions, + viewerIsAuthor: annotation.viewerIsAuthor, + }; + }), + commits: activity.commits, + reviewThreads: conversation.reviewThreads, + reactions: conversation.reactions, + })), + ), + + getDiff: (input) => + run({ + operation: "diff", + cwd: input.cwd, + args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"], + timeoutMs: DIFF_TIMEOUT_MS, + maxOutputBytes: PULL_REQUEST_DIFF_MAX_BYTES, + }).pipe( + Effect.map((output) => + capPullRequestDiff({ patch: output.stdout, truncated: output.stdoutTruncated }), + ), + ), + + runAction: (input) => { + const [subcommand = input.action, ...flags] = actionArgs({ + action: input.action, + mergeMethod: input.mergeMethod, + updateMethod: input.updateMethod, + deleteBranch: input.deleteBranch, + }); + return run({ + operation: "runAction", + cwd: input.cwd, + args: ["pr", subcommand, String(input.number), ...repositoryArgs(input), ...flags], + ...(input.action === "merge" ? { timeoutMs: MERGE_TIMEOUT_MS } : {}), + }).pipe(Effect.asVoid); + }, + + comment: (input) => + withBodyFile("comment", input.body).pipe( + Effect.flatMap((bodyFile) => + run({ + operation: "comment", + cwd: input.cwd, + args: [ + "pr", + "comment", + String(input.number), + ...repositoryArgs(input), + "--body-file", + bodyFile, + ], + }), + ), + Effect.map((output) => ({ url: parseResultUrl(output.stdout) })), + Effect.scoped, + ), + + submitReview: (input) => { + // A review with line comments has to go as one request body: `gh pr + // review` has no way to carry them, and nothing in the review is visible + // to anyone else until the whole thing lands. + if (input.comments.length > 0) { + const { owner, name } = repositoryParts(input.repository); + return run({ + operation: "submitReview", + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + `repos/${owner}/${name}/pulls/${input.number}/reviews`, + "--input", + "-", + ], + stdin: buildGitHubReviewSubmissionJson({ + verdict: input.verdict, + body: input.body, + comments: input.comments, + }), + }).pipe(Effect.map((output) => ({ url: parseResultUrl(output.stdout) }))); + } + + return Effect.gen(function* () { + const bodyArgs = + input.body.trim().length === 0 + ? [] + : ["--body-file", yield* withBodyFile("submitReview", input.body)]; + const output = yield* run({ + operation: "submitReview", + cwd: input.cwd, + args: [ + "pr", + "review", + String(input.number), + ...repositoryArgs(input), + `--${input.verdict}`, + ...bodyArgs, + ], + }); + return { url: parseResultUrl(output.stdout) }; + }).pipe(Effect.scoped); + }, + + replyToThread: (input) => + graphql({ + operation: "replyToThread", + cwd: input.cwd, + query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + variables: { threadId: input.threadId, body: input.body }, + }).pipe(Effect.asVoid), + + setThreadResolution: (input) => + graphql({ + operation: "setThreadResolution", + cwd: input.cwd, + query: input.resolved + ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION + : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + variables: { threadId: input.threadId }, + }).pipe(Effect.asVoid), + + setReaction: (input) => { + const given = input.subjectId; + const subjectId = + given === undefined + ? pullRequestNodeId("setReaction", input) + : requireSubjectInPullRequest("setReaction", { ...input, subjectId: given }); + return subjectId.pipe( + Effect.flatMap((resolved) => + graphql({ + operation: "setReaction", + cwd: input.cwd, + query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId: resolved, content: gitHubReactionContent(input.content) }, + }), + ), + Effect.asVoid, + ); + }, + + updateChangeRequest: (input) => + Effect.gen(function* () { + const bodyArgs = + input.body === undefined + ? [] + : ["--body-file", yield* withBodyFile("update", input.body)]; + yield* run({ + operation: "update", + cwd: input.cwd, + args: [ + "pr", + "edit", + String(input.number), + ...repositoryArgs(input), + ...(input.title === undefined ? [] : ["--title", input.title]), + ...bodyArgs, + ], + }); + }).pipe(Effect.scoped), + + updateComment: (input) => + requireSubjectInPullRequest("updateComment", { + ...input, + subjectId: input.commentId, + }).pipe( + Effect.flatMap((commentId) => + graphql({ + operation: "updateComment", + cwd: input.cwd, + query: + input.kind === "issue-comment" + ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION + : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + variables: { commentId, body: input.body }, + }), + ), + Effect.asVoid, + ), + + listReviewerCandidates: (input) => + graphqlRead({ + operation: "reviewerCandidates", + cwd: input.cwd, + query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, + variables: graphQlVariables(input), + decode: decodeGitHubReviewerCandidatesJson, + }), + + setReviewerRequest: (input) => { + const { owner, name } = repositoryParts(input.repository); + return run({ + operation: "requestReviewers", + cwd: input.cwd, + // GitHub takes a request back from exactly whoever it was made of, so + // the same body serves both methods. + args: [ + "api", + "--method", + input.requested ? "POST" : "DELETE", + `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + "--input", + "-", + ], + stdin: buildGitHubReviewerRequestJson(input.reviewers), + }).pipe(Effect.asVoid); + }, + + getRepositoryAccess: (input) => + run({ + operation: "repository", + cwd: input.cwd, + args: ["api", `repos/${input.repository}`], + }).pipe( + Effect.flatMap((output) => { + const decoded = decodeGitHubRepositoryJson(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError("repository", "repository", decoded.failure)); + }), + ), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts new file mode 100644 index 000000000..6b9efc91b --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -0,0 +1,254 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, afterEach, describe, 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 type * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockExecute = vi.fn(); + +const layer = Layer.mock(GitLabCli.GitLabCli)({ execute: mockExecute }); + +const repository = { cwd: "/workspaces/tools", repository: "acme/platform/tools" }; +const mergeRequestPath = "projects/acme%2Fplatform%2Ftools/merge_requests/7"; + +const calls = () => mockExecute.mock.calls.map(([input]) => input); + +afterEach(() => { + mockExecute.mockReset(); +}); + +describe("GitLabPullRequestProvider.runAction", () => { + const cases = [ + { + name: "merges now rather than letting glab arm the pipeline wait", + input: { action: "merge", mergeMethod: "squash" }, + args: [ + "mr", + "merge", + "7", + "--repo", + "acme/platform/tools", + "--auto-merge=false", + "--yes", + "--squash", + ], + }, + { + name: "arms auto-merge with the strategy stored alongside it", + input: { action: "enable-auto-merge", mergeMethod: "rebase" }, + args: [ + "mr", + "merge", + "7", + "--repo", + "acme/platform/tools", + "--auto-merge=true", + "--yes", + "--rebase", + ], + }, + { + name: "brings a stale branch up to date by rebasing, the only way GitLab has", + input: { action: "update-branch", updateMethod: "rebase" }, + args: ["mr", "rebase", "7", "--repo", "acme/platform/tools"], + }, + { + name: "turns a merge request back into a draft", + input: { action: "draft" }, + args: ["mr", "update", "7", "--repo", "acme/platform/tools", "--draft"], + }, + ] as const; + + for (const testCase of cases) { + it.effect(testCase.name, () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitLabPullRequestProvider.make(); + + yield* provider.runAction({ ...repository, number: 7, ...testCase.input }); + + assert.deepStrictEqual(calls()[0]?.args, testCase.args); + }).pipe(Effect.provide(layer)), + ); + } + + it.effect("disarms auto-merge through the API, which is the one direction glab lacks", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitLabPullRequestProvider.make(); + + yield* provider.runAction({ ...repository, number: 7, action: "disable-auto-merge" }); + + assert.deepStrictEqual(calls()[0]?.args, [ + "api", + `${mergeRequestPath}/cancel_merge_when_pipeline_succeeds`, + "--method", + "POST", + ]); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("GitLabPullRequestProvider.submitReview", () => { + it.effect("posts the line comments, then the summary, then the approval", () => + Effect.gen(function* () { + mockExecute.mockImplementation((input) => + Effect.succeed( + processOutput( + input.args[1]?.includes("include_diverged_commits_count") === true + ? JSON.stringify({ + iid: 7, + title: "Tidy the toolbox", + web_url: "https://gitlab.com/acme/platform/tools/-/merge_requests/7", + source_branch: "feature/tidy", + target_branch: "main", + created_at: "2026-08-30T10:00:00Z", + updated_at: "2026-08-31T10:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }) + : "", + ), + ), + ); + const provider = yield* GitLabPullRequestProvider.make(); + + yield* provider.submitReview({ + ...repository, + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [ + { path: "a.ts", position: { kind: "added", newLine: 12 }, body: "New line" }, + { + path: "now.ts", + oldPath: "was.ts", + position: { kind: "deleted", oldLine: 7 }, + body: "Old line", + }, + ], + }); + + const written = calls().filter((call) => call.args[2] === "--method"); + assert.deepStrictEqual( + written.map((call) => [call.args[1], call.args[3]]), + [ + [`${mergeRequestPath}/discussions`, "POST"], + [`${mergeRequestPath}/discussions`, "POST"], + [`${mergeRequestPath}/notes`, "POST"], + [`${mergeRequestPath}/approve`, "POST"], + ], + ); + assert.deepStrictEqual(JSON.parse(written[0]?.stdin ?? "{}"), { + body: "New line", + position: { + base_sha: "base", + head_sha: "head", + start_sha: "start", + position_type: "text", + old_path: "a.ts", + new_path: "a.ts", + new_line: 12, + }, + }); + assert.deepStrictEqual(JSON.parse(written[1]?.stdin ?? "{}"), { + body: "Old line", + position: { + base_sha: "base", + head_sha: "head", + start_sha: "start", + position_type: "text", + old_path: "was.ts", + new_path: "now.ts", + old_line: 7, + }, + }); + assert.deepStrictEqual(JSON.parse(written[2]?.stdin ?? "{}"), { body: "Looks right." }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("names the verdict in the note, GitLab having no refusal of its own", () => + Effect.gen(function* () { + mockExecute.mockReturnValue(Effect.succeed(processOutput(""))); + const provider = yield* GitLabPullRequestProvider.make(); + + yield* provider.submitReview({ + ...repository, + number: 7, + verdict: "request-changes", + body: "Please split this.", + comments: [], + }); + + assert.deepStrictEqual(calls()[0]?.args.slice(0, 4), [ + "api", + `${mergeRequestPath}/notes`, + "--method", + "POST", + ]); + assert.deepStrictEqual(JSON.parse(calls()[0]?.stdin ?? "{}"), { + body: "**Requested changes**\n\nPlease split this.", + }); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("GitLabPullRequestProvider.setReviewerRequest", () => { + it.effect("writes the whole reviewer set back, since GitLab replaces rather than adds", () => + Effect.gen(function* () { + mockExecute.mockImplementation((input) => + Effect.succeed( + processOutput( + input.args[2] === "--method" + ? "" + : JSON.stringify({ + iid: 7, + title: "Tidy the toolbox", + web_url: "https://gitlab.com/acme/platform/tools/-/merge_requests/7", + source_branch: "feature/tidy", + target_branch: "main", + created_at: "2026-08-30T10:00:00Z", + updated_at: "2026-08-31T10:00:00Z", + reviewers: [ + { id: 9, username: "hubot" }, + { id: 10, username: "monalisa" }, + ], + }), + ), + ), + ); + const provider = yield* GitLabPullRequestProvider.make(); + + yield* provider.setReviewerRequest({ + ...repository, + number: 7, + reviewers: [{ id: "10", kind: "user" }], + requested: false, + }); + + const write = calls().at(-1); + assert.deepStrictEqual(write?.args, [ + "api", + mergeRequestPath, + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + assert.deepStrictEqual(JSON.parse(write?.stdin ?? "{}"), { reviewer_ids: [9] }); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts new file mode 100644 index 000000000..bcc046424 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -0,0 +1,761 @@ +import type * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import type * as Schema from "effect/Schema"; + +import type { + PullRequestAction, + PullRequestActivity, + PullRequestCapabilities, + PullRequestComment, + PullRequestListState, + PullRequestMergeMethod, + PullRequestReviewer, + PullRequestReviewThread, +} from "@threadlines/contracts"; +import { formatSchemaError } from "@threadlines/shared/schemaJson"; + +import type * as GitLabCliModule from "../sourceControl/GitLabCli.ts"; +import { GitLabCli } from "../sourceControl/GitLabCli.ts"; +import { + buildGitLabDiscussionJson, + buildGitLabGraphQlRequestJson, + buildGitLabMergeRequestUpdateJson, + buildGitLabNoteBodyJson, + buildGitLabResolutionJson, + buildGitLabReviewerIdsJson, + decodeGitLabApprovalsJson, + decodeGitLabAwardsJson, + decodeGitLabCommitsJson, + decodeGitLabDiffsJson, + decodeGitLabDiscussionsJson, + decodeGitLabMergeRequestDetailJson, + decodeGitLabMergeRequestListJson, + decodeGitLabNotesJson, + decodeGitLabOwnAwardIdJson, + decodeGitLabProjectJson, + decodeGitLabProjectUsersJson, + decodeGitLabViewerJson, + GITLAB_AWARD_EMOJI_GRAPHQL_QUERY, + gitLabAwardName, + type GitLabDiffRefs, +} from "./gitLabMergeRequest.ts"; +import { capPullRequestDiff, PULL_REQUEST_DIFF_MAX_BYTES } from "./pullRequestDiff.ts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const PROVIDER_KIND = "gitlab" as const; +const DIFF_TIMEOUT_MS = 60_000; +/** GitLab's own ceiling on `per_page`. */ +const MAX_PAGE_SIZE = 100; +/** A merge waits on GitLab settling its pipeline and its merge train. */ +const MERGE_TIMEOUT_MS = 60_000; + +/** + * Everything GitLab lets a reader do here. The project narrows `mergeMethods`, + * because GitLab settles on one strategy per project rather than per merge + * request. + */ +export const GITLAB_PULL_REQUEST_CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "close", + "reopen", + "ready", + "draft", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + mergeMethods: ["merge", "squash", "rebase"], + // Rebase alone: GitLab moves a stale branch onto its target by replaying it, + // and has nothing that merges the target back in the way GitHub's update + // button can. Declaring only what it does is what lets a request to merge the + // target in be refused rather than quietly rebasing. + updateMethods: ["rebase"], + reactions: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + // GitLab records an approval and nothing that says a merge request was + // reviewed and rejected, so a refusal goes as a note naming the verdict. + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { pullRequest: true, comment: true }, +}; + +/** The heading a refusal carries, GitLab having no verdict of its own for it. */ +const REQUEST_CHANGES_HEADING = "**Requested changes**"; + +/** Turns a `glab` failure into the reason the page renders an action for. */ +export function classifyGitLabFailure(detail: string): PullRequestProviderError["reason"] { + const lower = detail.toLowerCase(); + if ( + lower.includes("not available on path") || + lower.includes("command not found") || + lower.includes("enoent") + ) { + return "missing-tool"; + } + if ( + lower.includes("not authenticated") || + lower.includes("not logged in") || + lower.includes("authentication") || + lower.includes("auth login") || + lower.includes("401") + ) { + return "unauthenticated"; + } + if (lower.includes("rate limit") || lower.includes("429")) { + return "rate-limited"; + } + return "failed"; +} + +/** + * The line of a `glab` failure worth showing. The CLI stacks its own wrapper + * around GitLab's complaint, and only the last line of that stack is the host + * talking. + */ +function lastFailureLine(detail: string): string { + const lines = detail + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return lines.length > 1 ? (lines[lines.length - 1] ?? detail.trim()) : detail.trim(); +} + +function toProviderError(operation: string, error: GitLabCliModule.GitLabCliError) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: classifyGitLabFailure(error.detail), + detail: lastFailureLine(error.detail), + }); +} + +function decodeError(operation: string, subject: string, failure: Cause.Cause) { + return new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: `GitLab CLI returned invalid ${subject} JSON: ${formatSchemaError(failure)}`, + }); +} + +/** A GitLab project is addressed by its whole path, encoded as one segment. */ +function projectPath(repository: string): string { + return encodeURIComponent(repository.trim()); +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +/** GitLab's own name for each slice; `closed` already excludes merged ones. */ +function stateParam(state: PullRequestListState): string { + return state === "open" ? "opened" : state; +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + const strategy = + mergeMethod === "squash" ? ["--squash"] : mergeMethod === "rebase" ? ["--rebase"] : []; + switch (action) { + // glab arms auto-merge whenever a pipeline is running. The button means merge now. + case "merge": + return ["merge", "--auto-merge=false", "--yes", ...strategy]; + // The same command with the flag the other way up: here the wait is the point. + case "enable-auto-merge": + return ["merge", "--auto-merge=true", "--yes", ...strategy]; + case "ready": + return ["update", "--ready"]; + case "draft": + return ["update", "--draft"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + // A rebase, because GitLab has no merge-the-target-in equivalent of + // GitHub's update button, which is why this host declares `rebase` alone. + case "update-branch": + return ["rebase"]; + // Never reached: taking the arming back has no `glab mr` command, so the + // provider sends it to the API instead. + case "disable-auto-merge": + return []; + } +} + +export const make = Effect.fn("makeGitLabPullRequestProvider")(function* () { + const gitlab = yield* GitLabCli; + + const run = (input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + }) => + gitlab + .execute({ + cwd: input.cwd, + args: input.args, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }) + .pipe(Effect.mapError((error) => toProviderError(input.operation, error))); + + /** + * One REST call through `glab api`. A body travels on stdin, so a reader's + * own words never reach argv; unlike `gh`, `glab api --input` sends no content + * type at all and GitLab answers a bodyless one with HTTP 415. + */ + const api = (input: { + readonly operation: string; + readonly cwd: string; + readonly path: string; + readonly method?: "POST" | "PUT" | "DELETE"; + readonly stdin?: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + }) => + run({ + operation: input.operation, + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + ...(input.stdin === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }); + + const apiRead = (input: { + readonly operation: string; + readonly subject: string; + readonly cwd: string; + readonly path: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly decode: (raw: string) => Result.Result>; + }) => + api(input).pipe( + Effect.flatMap((output) => { + const decoded = input.decode(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.operation, input.subject, decoded.failure)); + }), + ); + + const mergeRequestPath = (input: ProviderRepositoryRef & { readonly number: number }) => + `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; + + const readViewer = (operation: string, cwd: string) => + apiRead({ + operation, + subject: "user", + cwd, + path: "user", + decode: decodeGitLabViewerJson, + }); + + /** The merge request itself, which several calls need different parts of. */ + const readDetail = ( + operation: string, + input: ProviderRepositoryRef & { readonly number: number }, + ) => + apiRead({ + operation, + subject: "merge request", + cwd: input.cwd, + // How far behind the target branch this one is comes only when asked for + // by name, and it is asked for here because it is the same merge request. + path: `${mergeRequestPath(input)}?${query([["include_diverged_commits_count", "true"]])}`, + decode: decodeGitLabMergeRequestDetailJson, + }); + + const requireDiffRefs = ( + operation: string, + input: ProviderRepositoryRef & { readonly number: number }, + ) => + readDetail(operation, input).pipe( + Effect.flatMap((row): Effect.Effect => + row.diffRefs === null + ? Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation, + reason: "failed", + detail: "This merge request reported no revisions to place a comment against.", + }), + ) + : Effect.succeed(row.diffRefs), + ), + ); + + /** Where an award is written: a note of the merge request, or the request. */ + const awardPath = ( + input: ProviderRepositoryRef & { readonly number: number; readonly subjectId?: string }, + ) => { + const base = mergeRequestPath(input); + return input.subjectId === undefined + ? `${base}/award_emoji` + : `${base}/notes/${encodeURIComponent(input.subjectId)}/award_emoji`; + }; + + const provider: PullRequestProviderApi = { + kind: PROVIDER_KIND, + capabilities: GITLAB_PULL_REQUEST_CAPABILITIES, + + getViewer: (input) => readViewer("getViewer", input.cwd), + + listChangeRequests: (input) => + apiRead({ + operation: "list", + subject: "MR list", + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests?${query([ + ["state", stateParam(input.state)], + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(Math.min(input.limit, MAX_PAGE_SIZE))], + ])}`, + decode: decodeGitLabMergeRequestListJson, + }).pipe( + Effect.map((rows) => + rows.map((row) => ({ + ...row, + // GitLab reports neither added nor removed lines on a merge + // request, so the row omits the stat rather than inventing one. + additions: 0, + deletions: 0, + })), + ), + ), + + getChangeRequest: (input) => + Effect.all( + [ + readDetail("detail", input), + // Approval is the only per-reviewer verdict GitLab records, and it + // lives on an endpoint of its own. A refusal to answer costs the + // reviewers their verdicts, not the reader their detail. + apiRead({ + operation: "detail", + subject: "approvals", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/approvals`, + decode: decodeGitLabApprovalsJson, + }).pipe(Effect.catch(() => Effect.succeed>([]))), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([row, approvals]) => { + const approved = new Set(approvals.map((login) => login.toLowerCase())); + const reviewers = row.reviewers.map((reviewer): PullRequestReviewer => ({ + id: reviewer.id, + kind: "user", + login: reviewer.login, + state: approved.has(reviewer.login.toLowerCase()) ? "approved" : "pending", + })); + // Somebody may approve without having been asked, which GitLab + // reports on the approvals endpoint alone. + const listed = new Set(reviewers.map((reviewer) => reviewer.login.toLowerCase())); + for (const login of approvals) { + if (!listed.has(login.toLowerCase())) { + reviewers.push({ id: login, kind: "user", login, state: "approved" }); + } + } + return { + ...row, + additions: 0, + deletions: 0, + reviewers, + // A GitLab too old to count the divergence says nothing rather than + // "up to date": a missing banner beats a wrong all-clear. + baseComparison: + row.behindBy === null ? "unknown" : row.behindBy > 0 ? "behind" : "up-to-date", + ...(approvals.length > 0 ? { reviewDecision: "approved" as const } : {}), + }; + }), + ), + + getChangeRequestActivity: (input) => + Effect.gen(function* () { + // GitLab names who wrote a note but never whether that is the reader, + // so the account is read once and the comparison made here. + const viewer = yield* readViewer("activity", input.cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const page = query([["per_page", String(MAX_PAGE_SIZE)]]); + const [comments, threads, commits, awards] = yield* Effect.all( + [ + apiRead({ + operation: "activity", + subject: "notes", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/notes?${page}&${query([ + ["order_by", "created_at"], + ["sort", "asc"], + ])}`, + decode: (raw) => decodeGitLabNotesJson(raw, viewer), + }), + apiRead({ + operation: "activity", + subject: "discussions", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/discussions?${page}`, + decode: (raw) => decodeGitLabDiscussionsJson(raw, viewer), + }), + apiRead({ + operation: "activity", + subject: "commits", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/commits?${page}`, + decode: decodeGitLabCommitsJson, + }), + // The notes endpoint carries no award of any kind, so they are read + // beside it. A failed read costs the conversation its reactions + // rather than its words. + api({ + operation: "activity", + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: buildGitLabGraphQlRequestJson({ + query: GITLAB_AWARD_EMOJI_GRAPHQL_QUERY, + variables: { fullPath: input.repository, iid: String(input.number) }, + }), + }).pipe( + Effect.flatMap((output) => { + const decoded = decodeGitLabAwardsJson(output.stdout.trim(), viewer); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError("activity", "awards", decoded.failure)); + }), + Effect.catch(() => + Effect.succeed({ + reactions: [], + reactionsByNoteId: new Map>(), + }), + ), + ), + ], + { concurrency: 4 }, + ); + + return { + comments: comments.map((comment): PullRequestComment => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + commits, + reviewThreads: threads.map((thread): PullRequestReviewThread => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + })), + reactions: awards.reactions, + } satisfies PullRequestActivity; + }), + + getDiff: (input) => + api({ + operation: "diff", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/diffs?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", "1"], + ])}`, + timeoutMs: DIFF_TIMEOUT_MS, + maxOutputBytes: PULL_REQUEST_DIFF_MAX_BYTES, + }).pipe( + Effect.flatMap((output) => { + // GitLab hands its diff over as JSON, so a byte-truncated answer is a + // broken document rather than a short patch: there is nothing to + // salvage, and saying so beats a decode failure nobody can read. + if (output.stdoutTruncated) { + return Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation: "diff", + reason: "failed", + detail: "This merge request's diff was too large to read.", + }), + ); + } + const decoded = decodeGitLabDiffsJson(output.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed( + capPullRequestDiff({ + patch: decoded.success.patch, + // A full page means GitLab has more files than this read asked + // for, so the patch stops short of the whole change set. + truncated: decoded.success.truncated || decoded.success.rawCount >= MAX_PAGE_SIZE, + }), + ) + : Effect.fail(decodeError("diff", "diff", decoded.failure)); + }), + ), + + runAction: (input) => { + // `glab mr merge` arms auto-merge and never disarms it, so the one + // direction the CLI has no flag for goes to the API instead. + if (input.action === "disable-auto-merge") { + return api({ + operation: "runAction", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/cancel_merge_when_pipeline_succeeds`, + method: "POST", + }).pipe(Effect.asVoid); + } + const [subcommand = input.action, ...flags] = actionArgs(input.action, input.mergeMethod); + return run({ + operation: "runAction", + cwd: input.cwd, + args: ["mr", subcommand, String(input.number), "--repo", input.repository, ...flags], + ...(input.action === "merge" ? { timeoutMs: MERGE_TIMEOUT_MS } : {}), + }).pipe(Effect.asVoid); + }, + + comment: (input) => + api({ + operation: "comment", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/notes`, + method: "POST", + stdin: buildGitLabNoteBodyJson(input.body), + }).pipe( + // GitLab answers with the note, whose own URL it does not carry; the + // merge request page is where the reader finds it. + Effect.as({ url: null }), + ), + + submitReview: (input) => + Effect.gen(function* () { + // 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 leaves what already + // landed 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* requireDiffRefs("submitReview", input); + yield* Effect.forEach( + input.comments, + (comment) => + api({ + operation: "submitReview", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/discussions`, + method: "POST", + stdin: buildGitLabDiscussionJson({ comment, refs }), + }), + { discard: true }, + ); + } + + const body = input.body.trim(); + if (body.length > 0 || input.verdict === "request-changes") { + yield* api({ + operation: "submitReview", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/notes`, + method: "POST", + // GitLab records no "changes requested", so the refusal is a note + // that says so in its first line. + stdin: buildGitLabNoteBodyJson( + input.verdict === "request-changes" + ? `${REQUEST_CHANGES_HEADING}\n\n${body}` + : input.body, + ), + }); + } + + if (input.verdict === "approve") { + yield* api({ + operation: "submitReview", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/approve`, + method: "POST", + }); + } + + return { url: null }; + }), + + replyToThread: (input) => + api({ + operation: "replyToThread", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/discussions/${encodeURIComponent(input.threadId)}/notes`, + method: "POST", + stdin: buildGitLabNoteBodyJson(input.body), + }).pipe(Effect.asVoid), + + setThreadResolution: (input) => + api({ + operation: "setThreadResolution", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/discussions/${encodeURIComponent(input.threadId)}`, + method: "PUT", + stdin: buildGitLabResolutionJson(input.resolved), + }).pipe(Effect.asVoid), + + // Every subject reachable here is addressed under this merge request's own + // path, so an id from elsewhere cannot be written to: GitLab answers 404 + // for a note that does not hang off it. + setReaction: (input) => + Effect.gen(function* () { + const subject = awardPath(input); + if (input.reacted) { + yield* api({ + operation: "setReaction", + cwd: input.cwd, + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + return; + } + // GitLab deletes an award by its id and takes no emoji name there, so + // the reader's own award is looked up first. Nothing to delete is + // success: the reaction the caller asked to take back is already gone. + const viewer = yield* readViewer("setReaction", input.cwd); + if (viewer === null) { + return yield* Effect.fail( + new PullRequestProviderError({ + provider: PROVIDER_KIND, + operation: "setReaction", + reason: "unauthenticated", + detail: "GitLab did not name the signed-in account.", + }), + ); + } + const listed = yield* api({ + operation: "setReaction", + cwd: input.cwd, + path: subject, + }); + const own = decodeGitLabOwnAwardIdJson(listed.stdout.trim(), { + content: input.content, + viewer, + }); + if (!Result.isSuccess(own)) { + return yield* Effect.fail(decodeError("setReaction", "awards", own.failure)); + } + if (own.success === null) { + return; + } + yield* api({ + operation: "setReaction", + cwd: input.cwd, + path: `${subject}/${own.success}`, + method: "DELETE", + }); + }), + + updateChangeRequest: (input) => + api({ + operation: "update", + cwd: input.cwd, + path: mergeRequestPath(input), + method: "PUT", + stdin: buildGitLabMergeRequestUpdateJson({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }), + }).pipe(Effect.asVoid), + + // The kind is not read: every remark this provider hands out, positioned or + // not, carries a plain REST note id, and one endpoint rewrites both. + updateComment: (input) => + api({ + operation: "updateComment", + cwd: input.cwd, + path: `${mergeRequestPath(input)}/notes/${encodeURIComponent(input.commentId)}`, + method: "PUT", + stdin: buildGitLabNoteBodyJson(input.body), + }).pipe(Effect.asVoid), + + // Users only: GitLab asks a person for a review, and the groups that stand + // in for one live in approval rules rather than on a merge request. + listReviewerCandidates: (input) => + Effect.all( + [ + apiRead({ + operation: "reviewerCandidates", + subject: "project users", + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/users?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ])}`, + decode: decodeGitLabProjectUsersJson, + }), + readDetail("reviewerCandidates", input), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([users, row]) => { + const requested = new Set(row.reviewers.map((reviewer) => reviewer.login.toLowerCase())); + const author = row.author?.login.toLowerCase() ?? null; + return { + // The author is dropped rather than shown unusable: GitLab refuses + // to make whoever opened a merge request its reviewer. + candidates: users.candidates.flatMap((candidate) => + candidate.login.toLowerCase() === author + ? [] + : [{ ...candidate, requested: requested.has(candidate.login.toLowerCase()) }], + ), + }; + }), + ), + + setReviewerRequest: (input) => + readDetail("requestReviewers", input).pipe( + Effect.flatMap((row) => + api({ + operation: "requestReviewers", + cwd: input.cwd, + path: mergeRequestPath(input), + method: "PUT", + stdin: buildGitLabReviewerIdsJson({ + current: row.reviewers.map((reviewer) => reviewer.id), + reviewers: input.reviewers, + requested: input.requested, + }), + }), + ), + Effect.asVoid, + ), + + getRepositoryAccess: (input) => + apiRead({ + operation: "repository", + subject: "project", + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}?license=false`, + decode: decodeGitLabProjectJson, + }), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts new file mode 100644 index 000000000..ede806da1 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -0,0 +1,307 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + SourceControlProviderKind, + type PullRequestAction, + type PullRequestActor, + type PullRequestActivity, + type PullRequestBaseComparison, + type PullRequestCapabilities, + type PullRequestCheck, + type PullRequestChecksState, + type PullRequestCommentResult, + type PullRequestCommentUpdateKind, + type PullRequestDiffResult, + type PullRequestLabel, + type PullRequestListState, + type PullRequestMergeability, + type PullRequestMergeMethod, + type PullRequestReactionContent, + type PullRequestReviewCommentDraft, + type PullRequestReviewDecision, + type PullRequestReviewer, + type PullRequestReviewerCandidateList, + type PullRequestReviewerKind, + type PullRequestReviewResult, + type PullRequestReviewVerdict, + type PullRequestState, + type PullRequestUpdateMethod, +} from "@threadlines/contracts"; + +/** + * The one failure shape every host reports through, so the service can decide + * what a failure means without knowing which CLI or API produced it. `reason` + * is the part the listing renders an action for. + */ +export class PullRequestProviderError extends Schema.TaggedError()( + "PullRequestProviderError", + { + provider: SourceControlProviderKind, + operation: Schema.String, + reason: Schema.Literals(["missing-tool", "unauthenticated", "rate-limited", "failed"]), + detail: Schema.String, + }, +) { + override get message(): string { + return `${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} + +/** Where a call runs, and which repository on the host it addresses. */ +export interface ProviderRepositoryRef { + /** A checkout the host's tool is run in; the credentials come with it. */ + readonly cwd: string; + /** + * Host-native repository identity: `owner/name` for GitHub and Bitbucket, the + * whole group path for GitLab, and the repository's own name for Azure + * DevOps, whose tool reads the rest from the checkout. + */ + readonly repository: string; +} + +/** + * One change request as a listing carries it, before the service attaches the + * project it belongs to and how it relates to the viewer. + */ +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 additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + /** Accounts with a review outstanding; team requests are dropped by each host. */ + readonly reviewRequestedLogins: ReadonlyArray; + readonly labels: ReadonlyArray; + /** Absent from a host that does not summarise its reviews. */ + readonly reviewDecision?: PullRequestReviewDecision; + /** Absent where there are no checks, or where the listing did not ask for them. */ + readonly checksState?: PullRequestChecksState; +} + +/** + * One of the viewer's own change requests, found by searching the whole host + * rather than by asking about a repository, so it names the one it is on. + */ +export interface ProviderAuthoredChangeRequest extends ProviderChangeRequest { + /** Host-native repository identity, the same spelling a listing takes. */ + readonly repository: string; +} + +export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly body: string; + readonly changedFiles: number; + readonly mergeability: PullRequestMergeability; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + readonly baseComparison: PullRequestBaseComparison; + /** Null where the host could not compare the branch with its base. */ + readonly behindBy: number | null; + /** Null where the host does not say whether it is armed to merge on its own. */ + readonly autoMergeEnabled: boolean | null; +} + +/** + * What the repository itself allows. Read once per repository and cached, + * because it changes far more rarely than any pull request on it. + */ +export interface ProviderRepositoryAccess { + readonly canWrite: boolean; + readonly mergeMethods: ReadonlyArray; + /** What a pull request has to target not to be stacked on other work. */ + readonly defaultBranch: string | null; +} + +/** + * One host's pull requests. An implementation owns its own tool and JSON shapes + * and answers with the neutral contract types; anything a host cannot do is + * declared in `capabilities` rather than failing at call time. + */ +export interface PullRequestProviderApi { + readonly kind: SourceControlProviderKind; + /** + * What this host supports in general. `mergeMethods` here is the host's whole + * set; the service narrows it to what a repository allows before the detail + * carries it. + */ + readonly capabilities: PullRequestCapabilities; + + /** The signed-in account, or null when the host would not say. */ + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listChangeRequests: ( + input: ProviderRepositoryRef & { + readonly state: PullRequestListState; + readonly limit: number; + }, + ) => Effect.Effect, PullRequestProviderError>; + + /** + * The viewer's own change requests anywhere on this host, including + * repositories the workspace does not point at. Absent from a host with no + * such search, whose listing is then only what the workspace covers. The + * checkout is only where the tool runs; it does not narrow the answer. + */ + readonly listAuthoredChangeRequests?: (input: { + readonly cwd: string; + readonly viewer: string; + readonly state: PullRequestListState; + readonly limit: number; + }) => Effect.Effect, PullRequestProviderError>; + + readonly getChangeRequest: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** The conversation, the line threads, and the commits, read on their own. */ + readonly getChangeRequestActivity: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Only called when `capabilities.diff` is true. */ + readonly getDiff: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Only called for an action the host declared in `capabilities.actions`. */ + readonly runAction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly action: PullRequestAction; + /** Meaningful for `merge` and `enable-auto-merge`. */ + readonly mergeMethod?: PullRequestMergeMethod; + /** Meaningful for `update-branch`; absent takes the host's own default. */ + readonly updateMethod?: PullRequestUpdateMethod; + /** Meaningful for `merge`; leaves the head branch standing when absent. */ + readonly deleteBranch?: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.comment` is true. */ + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, + ) => Effect.Effect; + + /** + * Sends a whole review at once, so nothing in it is visible to anyone else + * before the verdict goes. Only called for a verdict the host declared, and + * with line comments only where it declared `review.inlineComment`. + */ + readonly submitReview: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }, + ) => 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; + + /** + * Adds a reaction or takes it back. An absent `subjectId` means the change + * request's own description. The provider confirms a given subject belongs to + * this pull request before it writes, so an id from elsewhere is refused. + */ + readonly setReaction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly subjectId?: string; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.edit.pullRequest` is true, never with both fields absent. */ + readonly updateChangeRequest: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly title?: string; + readonly body?: string; + }, + ) => Effect.Effect; + + /** + * Rewrites a remark. Whether it is the reader's to rewrite is the host's own + * answer: access can be taken away between the read and the write. + */ + readonly updateComment: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commentId: string; + readonly kind: PullRequestCommentUpdateKind; + readonly body: string; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.reviewers.listCandidates` is true. */ + readonly listReviewerCandidates: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Asks for a review or takes the ask back. One call for both directions, + * because that is what every host does with them. Only called when + * `capabilities.reviewers.request` is true. + */ + readonly setReviewerRequest: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + readonly requested: boolean; + }, + ) => Effect.Effect; + + readonly getRepositoryAccess: ( + input: ProviderRepositoryRef, + ) => Effect.Effect; +} + +export interface PullRequestProviderRegistryShape { + /** Null for a host with no implementation, whose projects are skipped. */ + readonly get: (kind: SourceControlProviderKind) => PullRequestProviderApi | null; +} + +export class PullRequestProviderRegistry extends Context.Service< + PullRequestProviderRegistry, + PullRequestProviderRegistryShape +>()("threadlines/pullRequest/PullRequestProviderRegistry") {} + +/** Exported for tests, which stand a registry up from providers they supply themselves. */ +export function fromProviders( + providers: ReadonlyArray, +): PullRequestProviderRegistryShape { + const byKind = new Map(providers.map((provider) => [provider.kind, provider])); + return { get: (kind) => byKind.get(kind) ?? null }; +} diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts new file mode 100644 index 000000000..18d06a7ab --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; +import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import { fromProviders, PullRequestProviderRegistry } from "./PullRequestProvider.ts"; + +/** + * The hosts this build reads pull requests from. A project on a host with no + * entry here is skipped by the listing rather than reported as a failure, which + * is what a repository on an unrecognised remote gets. + */ +export const make = Effect.map( + Effect.all( + [ + GitHubPullRequestProvider.make(), + GitLabPullRequestProvider.make(), + BitbucketPullRequestProvider.make(), + AzureDevOpsPullRequestProvider.make(), + ], + { concurrency: 1 }, + ), + (providers) => fromProviders(providers), +); + +export const layer = Layer.effect(PullRequestProviderRegistry, make); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index e8dfd073d..c23b20a97 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics preferSchemaOverJson:off +import { readFileSync } from "node:fs"; import { assert, afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -10,8 +12,18 @@ import { } from "@threadlines/contracts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +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 VcsProcess from "../vcs/VcsProcess.ts"; +import { GITHUB_PULL_REQUEST_CAPABILITIES } from "./GitHubPullRequestProvider.ts"; +import { + fromProviders, + PullRequestProviderRegistry, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import * as PullRequestProviderRegistryLayer from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ @@ -35,7 +47,11 @@ const project = (input: { readonly provider: string; readonly repository: string; }): OrchestrationProjectShell => { - const [owner = "", name = ""] = input.repository.split("/"); + // The identity resolver records the whole path below the host as + // `displayName`, its first segment as `owner` and its last as `name`. + const segments = input.repository.split("/").filter((segment) => segment.length > 0); + const owner = segments[0] ?? ""; + const name = segments.at(-1) ?? ""; return { id: ProjectId.make(input.id), kind: "workspace", @@ -48,6 +64,7 @@ const project = (input: { remoteName: "origin", remoteUrl: `https://example.com/${input.repository}.git`, }, + displayName: input.repository, provider: input.provider, owner, name, @@ -83,17 +100,68 @@ const pullRequestRow = (input: { }); const mockExecute = vi.fn(); +const mockGitLabExecute = vi.fn(); const mockGetShellSnapshot = vi.fn<() => Effect.Effect>(); +const projectionsLayer = Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: mockGetShellSnapshot, +}); + +/** Every host the registry builds, so the service can pick between them. */ +const hostClientsLayer = Layer.mergeAll( + Layer.mock(GitHubCli.GitHubCli)({ execute: mockExecute }), + Layer.mock(GitLabCli.GitLabCli)({ execute: mockGitLabExecute }), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), +); + const layer = PullRequestService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(GitHubCli.GitHubCli)({ execute: mockExecute }), - Layer.mock(ProjectionSnapshotQuery)({ getShellSnapshot: mockGetShellSnapshot }), - ), - ), + Layer.provide(Layer.mergeAll(PullRequestProviderRegistryLayer.layer, projectionsLayer)), + Layer.provide(hostClientsLayer), + Layer.provide(NodeServices.layer), ); +/** + * A host that answers nothing. Every method fails the test if the service ever + * reaches it, so a capability refusal is proved by the call never happening. + */ +const unreachable = (operation: string) => () => + Effect.sync(() => assert.fail(`the service reached ${operation} on a host that cannot do it`)); + +const stubProvider = ( + capabilities: PullRequestProviderApi["capabilities"], +): PullRequestProviderApi => ({ + kind: "github", + capabilities, + getViewer: () => Effect.succeed("octocat"), + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: unreachable("getChangeRequest"), + getChangeRequestActivity: unreachable("getChangeRequestActivity"), + getDiff: unreachable("getDiff"), + runAction: unreachable("runAction"), + comment: unreachable("comment"), + submitReview: unreachable("submitReview"), + replyToThread: unreachable("replyToThread"), + setThreadResolution: unreachable("setThreadResolution"), + setReaction: unreachable("setReaction"), + updateChangeRequest: unreachable("updateChangeRequest"), + updateComment: unreachable("updateComment"), + listReviewerCandidates: unreachable("listReviewerCandidates"), + setReviewerRequest: unreachable("setReviewerRequest"), + getRepositoryAccess: unreachable("getRepositoryAccess"), +}); + +/** The service over a host with the capabilities a test wants to take away. */ +const layerWithCapabilities = (capabilities: PullRequestProviderApi["capabilities"]) => + PullRequestService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(PullRequestProviderRegistry, fromProviders([stubProvider(capabilities)])), + projectionsLayer, + ), + ), + ); + const withProjects = (projects: ReadonlyArray) => { mockGetShellSnapshot.mockReturnValue( Effect.succeed({ @@ -110,16 +178,186 @@ const repositoryArg = (args: ReadonlyArray) => { return index < 0 ? null : (args[index + 1] ?? null); }; -const prListCalls = () => - mockExecute.mock.calls.filter(([input]) => input.args[0] === "pr").map(([input]) => input); +const prCalls = (subcommand: string) => + mockExecute.mock.calls + .filter(([input]) => input.args[0] === "pr" && input.args[1] === subcommand) + .map(([input]) => input); + +const prListCalls = () => prCalls("list"); + +const jsonFieldsArg = (args: ReadonlyArray) => { + const index = args.indexOf("--json"); + return index < 0 ? "" : (args[index + 1] ?? ""); +}; + +const bodyFileContents = (args: ReadonlyArray) => { + const index = args.indexOf("--body-file"); + const path = index < 0 ? undefined : args[index + 1]; + return path === undefined ? null : readFileSync(path, "utf8"); +}; + +/** The one project every per-pull-request read runs against. */ +const onlyGitHubProject = () => + withProjects([ + project({ + id: "project-app", + title: "Example App", + provider: "github", + repository: "octocat/example-app", + }), + ]); + +const repositoryJson = (input?: { + readonly push?: boolean; + readonly merge?: boolean; + readonly squash?: boolean; + readonly rebase?: boolean; +}) => + JSON.stringify({ + name: "example-app", + permissions: { admin: false, push: input?.push ?? true, pull: true }, + allow_merge_commit: input?.merge ?? true, + allow_squash_merge: input?.squash ?? true, + allow_rebase_merge: input?.rebase ?? true, + }); + +const detailJson = (input?: { readonly author?: string; readonly isDraft?: boolean }) => + JSON.stringify({ + ...pullRequestRow({ number: 12, author: input?.author ?? "hubot" }), + isDraft: input?.isDraft ?? false, + body: "Reads one pull request.", + changedFiles: 3, + mergeable: "MERGEABLE", + closedAt: null, + reviews: [], + statusCheckRollup: [], + }); + +/** One node of the account-wide search, in the shape its document asks for. */ +const authoredSearchNode = (input: { + readonly number: number; + readonly repository: string; + readonly author?: string; +}) => ({ + ...pullRequestRow({ number: input.number, author: input.author ?? "octocat" }), + author: { login: input.author ?? "octocat" }, + repository: { nameWithOwner: input.repository }, + labels: { nodes: [] }, + reviewRequests: { nodes: [] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, +}); + +const authoredSearchJson = (nodes: ReadonlyArray> = []) => + JSON.stringify({ data: { search: { nodes } } }); + +/** + * The GraphQL reads the listing, detail and activity paths make, answered by + * what the document asks for. Everything travels on stdin, so the document is + * there. + */ +const graphqlJson = (stdin: string | undefined) => { + const document = stdin ?? ""; + if (document.includes("search(query:")) { + return authoredSearchJson(); + } + if (document.includes("behindBy")) { + return JSON.stringify({ + data: { repository: { pullRequest: { baseRef: { compare: { behindBy: 0 } } } } }, + }); + } + if (document.includes("reviewThreads")) { + return JSON.stringify({ + data: { + repository: { + pullRequest: { + reactionGroups: [], + reviewThreads: { nodes: [] }, + comments: { nodes: [] }, + reviews: { nodes: [] }, + }, + }, + }, + }); + } + return JSON.stringify({ data: { repository: null } }); +}; + +/** Answers every read the detail path makes; `onWrite` sees everything else. */ +const hostAnswers = (handlers?: { + readonly repository?: string; + readonly detail?: () => string; + readonly onWrite?: (args: ReadonlyArray) => string; +}) => { + mockExecute.mockImplementation((input) => { + if (input.args[0] === "auth") { + return Effect.succeed(authStatusOutput("octocat")); + } + if (input.args[0] === "api" && input.args[1] === "graphql") { + return Effect.succeed(processOutput(graphqlJson(input.stdin))); + } + if (input.args[0] === "api") { + return Effect.succeed(processOutput(handlers?.repository ?? repositoryJson())); + } + if (input.args[1] === "list") { + return Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 12, author: "hubot" })])), + ); + } + if (input.args[1] === "view") { + return Effect.succeed( + processOutput( + jsonFieldsArg(input.args).startsWith("comments") + ? JSON.stringify({ comments: [], reviews: [], commits: [] }) + : (handlers?.detail ?? detailJson)(), + ), + ); + } + return Effect.succeed(processOutput(handlers?.onWrite?.(input.args) ?? "")); + }); +}; + +/** + * The two reads every listing makes on its own: who is signed in, and the + * account-wide search for the viewer's own pull requests. A test that cares + * about either answers it itself. + */ +const listAnswers = (handlers: { + readonly list: ( + args: ReadonlyArray, + ) => Effect.Effect; + readonly authored?: () => Effect.Effect; +}) => { + mockExecute.mockImplementation((input) => { + if (input.args[0] === "auth") { + return Effect.succeed(authStatusOutput("octocat")); + } + if (input.args[0] === "api" && input.args[1] === "graphql") { + return handlers.authored === undefined + ? Effect.succeed(processOutput(authoredSearchJson())) + : handlers.authored(); + } + return handlers.list(input.args); + }); +}; + +/** The one row a listing test that is not about the rows themselves answers with. */ +const oneOpenRow = () => + Effect.succeed(processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })]))); + +const pullRequestReference = { + projectId: ProjectId.make("project-app"), + repository: "octocat/example-app", + number: 12, +}; afterEach(() => { mockExecute.mockReset(); + mockGitLabExecute.mockReset(); mockGetShellSnapshot.mockReset(); }); describe("PullRequestService.list", () => { - it.effect("lists GitHub projects and silently skips the others", () => + it.effect("lists the projects a host answers for and silently skips the others", () => Effect.gen(function* () { withProjects([ project({ @@ -131,17 +369,11 @@ describe("PullRequestService.list", () => { project({ id: "project-tools", title: "Tools", - provider: "gitlab", + provider: "unknown", repository: "octocat/tools", }), ]); - mockExecute.mockImplementation((input) => - input.args[0] === "auth" - ? Effect.succeed(authStatusOutput("octocat")) - : Effect.succeed( - processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), - ), - ); + listAnswers({ list: oneOpenRow }); const service = yield* PullRequestService.PullRequestService; const result = yield* service.list({ state: "open" }); @@ -158,6 +390,69 @@ describe("PullRequestService.list", () => { }).pipe(Effect.provide(layer)), ); + it.effect("reads a GitLab project through the GitLab provider", () => + Effect.gen(function* () { + withProjects([ + project({ + id: "project-tools", + title: "Tools", + provider: "gitlab", + repository: "acme/platform/tools", + }), + ]); + mockGitLabExecute.mockImplementation((input) => + Effect.succeed( + processOutput( + input.args[1] === "user" + ? JSON.stringify({ username: "octocat" }) + : JSON.stringify([ + { + iid: 7, + title: "Tidy the toolbox", + web_url: "https://gitlab.com/acme/platform/tools/-/merge_requests/7", + author: { id: 3, username: "octocat" }, + source_branch: "feature/tidy", + target_branch: "main", + state: "opened", + created_at: "2026-08-30T10:00:00Z", + updated_at: "2026-08-31T10:00:00Z", + reviewers: [{ id: 9, username: "hubot" }], + }, + ]), + ), + ), + ); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [ + entry.provider, + entry.repository, + entry.number, + entry.viewerIsAuthor, + ]), + [["gitlab", "acme/platform/tools", 7, true]], + ); + assert.deepStrictEqual(result.errors, []); + // The nested group path travels whole, encoded as one path segment. + assert.deepStrictEqual( + mockGitLabExecute.mock.calls + .map(([input]) => input.args) + .filter((args) => args[1] !== "user"), + [ + [ + "api", + "projects/acme%2Fplatform%2Ftools/merge_requests?state=opened&order_by=updated_at&sort=desc&per_page=50", + ], + ], + ); + // `gh` is never reached for a project on another host. + assert.deepStrictEqual(mockExecute.mock.calls, []); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads a repository once when several projects point at it", () => Effect.gen(function* () { withProjects([ @@ -174,13 +469,12 @@ describe("PullRequestService.list", () => { repository: "Octocat/Example-App", }), ]); - mockExecute.mockImplementation((input) => - input.args[0] === "auth" - ? Effect.succeed(authStatusOutput("octocat")) - : Effect.succeed( - processOutput(JSON.stringify([pullRequestRow({ number: 7, author: "hubot" })])), - ), - ); + listAnswers({ + list: () => + Effect.succeed( + processOutput(JSON.stringify([pullRequestRow({ number: 7, author: "hubot" })])), + ), + }); const service = yield* PullRequestService.PullRequestService; const result = yield* service.list({ state: "open" }); @@ -206,27 +500,26 @@ describe("PullRequestService.list", () => { repository: "octocat/example-app", }), ]); - mockExecute.mockImplementation((input) => - input.args[0] === "auth" - ? Effect.succeed(authStatusOutput("octocat")) - : Effect.succeed( - processOutput( - JSON.stringify([ - pullRequestRow({ number: 1, author: "OctoCat" }), - pullRequestRow({ - number: 2, - author: "hubot", - reviewRequests: [{ __typename: "User", login: "octocat" }], - }), - pullRequestRow({ - number: 3, - author: "hubot", - reviewRequests: [{ __typename: "Team", name: "core", slug: "core" }], - }), - ]), - ), + listAnswers({ + list: () => + Effect.succeed( + processOutput( + JSON.stringify([ + pullRequestRow({ number: 1, author: "OctoCat" }), + pullRequestRow({ + number: 2, + author: "hubot", + reviewRequests: [{ __typename: "User", login: "octocat" }], + }), + pullRequestRow({ + number: 3, + author: "hubot", + reviewRequests: [{ __typename: "Team", name: "core", slug: "core" }], + }), + ]), ), - ); + ), + }); const service = yield* PullRequestService.PullRequestService; const result = yield* service.list({ state: "open" }); @@ -263,20 +556,16 @@ describe("PullRequestService.list", () => { repository: "octocat/site", }), ]); - mockExecute.mockImplementation((input) => { - if (input.args[0] === "auth") { - return Effect.succeed(authStatusOutput("octocat")); - } - return repositoryArg(input.args) === "octocat/site" - ? Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "execute", - detail: "You are not logged into any GitHub hosts. Run gh auth login.", - }), - ) - : Effect.succeed( - processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), - ); + listAnswers({ + list: (args) => + repositoryArg(args) === "octocat/site" + ? Effect.fail( + new GitHubCli.GitHubCliError({ + operation: "execute", + detail: "You are not logged into any GitHub hosts. Run gh auth login.", + }), + ) + : oneOpenRow(), }); const service = yield* PullRequestService.PullRequestService; @@ -303,13 +592,7 @@ describe("PullRequestService.list", () => { repository: "octocat/example-app", }), ]); - mockExecute.mockImplementation((input) => - input.args[0] === "auth" - ? Effect.succeed(authStatusOutput("octocat")) - : Effect.succeed( - processOutput(JSON.stringify([pullRequestRow({ number: 1, author: "hubot" })])), - ), - ); + listAnswers({ list: oneOpenRow }); const service = yield* PullRequestService.PullRequestService; yield* service.list({ state: "open" }); @@ -320,4 +603,367 @@ describe("PullRequestService.list", () => { expect(prListCalls()).toHaveLength(2); }).pipe(Effect.provide(layer)), ); + + it.effect("adds the viewer's own pull requests from repositories the workspace has not", () => + Effect.gen(function* () { + onlyGitHubProject(); + listAnswers({ + list: oneOpenRow, + authored: () => + Effect.succeed( + processOutput( + authoredSearchJson([ + // The workspace already read this repository, so its own listing + // answers for it and the search must not repeat the row. + authoredSearchNode({ number: 1, repository: "Octocat/Example-App" }), + authoredSearchNode({ number: 4, repository: "openai/codex" }), + ]), + ), + ), + }); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [ + entry.origin, + entry.repository, + entry.number, + entry.projectId, + entry.projectTitle, + ]), + [ + ["workspace", "octocat/example-app", 1, "project-app", "Example App"], + // The anchor project is only where the host's tool runs, so the row + // names the repository it is really on. + ["authored", "openai/codex", 4, "project-app", "openai/codex"], + ], + ); + assert.deepStrictEqual(result.errors, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps the listing when the account-wide search fails", () => + Effect.gen(function* () { + onlyGitHubProject(); + listAnswers({ + list: oneOpenRow, + authored: () => + Effect.fail( + new GitHubCli.GitHubCliError({ + operation: "execute", + detail: "API rate limit exceeded for user.", + }), + ), + }); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0]?.projectId, "project-app"); + assert.equal(result.errors[0]?.repository, null); + assert.equal(result.errors[0]?.reason, "rate-limited"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("leaves the search alone for a caller that only wants the workspace", () => + Effect.gen(function* () { + onlyGitHubProject(); + listAnswers({ + list: oneOpenRow, + authored: () => Effect.sync(() => assert.fail("the search ran for a workspace-only list")), + }); + + const service = yield* PullRequestService.PullRequestService; + const result = yield* service.list({ state: "open", includeAuthored: false }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.origin), + ["workspace"], + ); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("PullRequestService pull request reads", () => { + const reference = pullRequestReference; + + it.effect("refuses a pull request whose project is not one this workspace reads", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers(); + + const service = yield* PullRequestService.PullRequestService; + const error = yield* service + .detail({ ...reference, projectId: ProjectId.make("project-gone") }) + .pipe(Effect.flip); + + assert.equal(error.detail, "Pull request is not in this workspace."); + assert.deepStrictEqual(mockExecute.mock.calls, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads a repository the project's own remote does not point at", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers(); + + const service = yield* PullRequestService.PullRequestService; + const detail = yield* service.detail({ ...reference, repository: "openai/codex" }); + + assert.equal(detail.repository, "openai/codex"); + // The project is only the checkout the tool runs in; the repository is + // whichever one the reference names. + assert.deepStrictEqual(prCalls("view")[0]?.args.slice(0, 5), [ + "pr", + "view", + "12", + "--repo", + "openai/codex", + ]); + assert.deepStrictEqual( + mockExecute.mock.calls + .map(([input]) => input.args) + .filter((args) => args[0] === "api" && args[1] !== "graphql"), + [["api", "repos/openai/codex"]], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reports what the viewer may do and how the repository allows a merge", () => + Effect.gen(function* () { + onlyGitHubProject(); + let author = "hubot"; + hostAnswers({ + repository: repositoryJson({ push: false, merge: false }), + detail: () => detailJson({ author }), + }); + + const service = yield* PullRequestService.PullRequestService; + const theirs = yield* service.detail(reference); + + assert.deepStrictEqual(theirs.viewer, { + canWrite: false, + canReview: true, + canManage: false, + }); + assert.deepStrictEqual(theirs.mergeMethods, ["squash", "rebase"]); + + author = "OctoCat"; + const mine = yield* service.detail({ ...reference, force: true }); + + // The author may close and rewrite their own pull request without any + // rights over the repository it is aimed at. + assert.deepStrictEqual(mine.viewer, { canWrite: false, canReview: false, canManage: true }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("posts a comment from a body file and drops that pull request's cached reads", () => + Effect.gen(function* () { + onlyGitHubProject(); + let commentBody: string | null = null; + hostAnswers({ + onWrite: (args) => { + commentBody = bodyFileContents(args); + return "https://github.com/octocat/example-app/pull/12#issuecomment-1\n"; + }, + }); + + const service = yield* PullRequestService.PullRequestService; + yield* service.detail(reference); + yield* service.activity(reference); + yield* service.detail(reference); + expect(prCalls("view")).toHaveLength(2); + + const result = yield* service.comment({ ...reference, body: "Looks good to me" }); + + assert.equal(result.url, "https://github.com/octocat/example-app/pull/12#issuecomment-1"); + assert.equal(commentBody, "Looks good to me"); + const commentArgs = prCalls("comment")[0]?.args ?? []; + assert.deepStrictEqual(commentArgs.slice(0, 5), [ + "pr", + "comment", + "12", + "--repo", + "octocat/example-app", + ]); + assert.equal(commentArgs.includes("Looks good to me"), false); + + yield* service.detail(reference); + yield* service.activity(reference); + expect(prCalls("view")).toHaveLength(4); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("PullRequestService pull request actions", () => { + const reference = pullRequestReference; + + it.effect("refuses a merge method the repository does not allow before running gh", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers({ repository: repositoryJson({ squash: false }) }); + + const service = yield* PullRequestService.PullRequestService; + const error = yield* service + .runAction({ ...reference, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.flip); + + assert.equal(error.detail, "This repository does not allow a squash merge."); + assert.deepStrictEqual(prCalls("merge"), []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("merges with the repository's first allowed method when the caller names none", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers({ repository: repositoryJson({ merge: false }) }); + + const service = yield* PullRequestService.PullRequestService; + yield* service.runAction({ ...reference, action: "merge" }); + + assert.deepStrictEqual(prCalls("merge")[0]?.args, [ + "pr", + "merge", + "12", + "--repo", + "octocat/example-app", + "--squash", + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("turns a pull request back into a draft and answers with the host's fresh state", () => + Effect.gen(function* () { + onlyGitHubProject(); + let isDraft = false; + hostAnswers({ + detail: () => detailJson({ isDraft }), + onWrite: () => { + isDraft = true; + return ""; + }, + }); + + const service = yield* PullRequestService.PullRequestService; + const before = yield* service.detail(reference); + assert.equal(before.isDraft, false); + yield* service.list({ state: "open" }); + + const result = yield* service.runAction({ ...reference, action: "draft" }); + + assert.deepStrictEqual(result, { state: "open", isDraft: true }); + assert.deepStrictEqual(prCalls("ready")[0]?.args, [ + "pr", + "ready", + "12", + "--repo", + "octocat/example-app", + "--undo", + ]); + + yield* service.list({ state: "open" }); + expect(prListCalls()).toHaveLength(2); + }).pipe(Effect.provide(layer)), + ); + + it.effect("refuses a request for changes with no comment before running gh", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers(); + + const service = yield* PullRequestService.PullRequestService; + const error = yield* service + .submitReview({ ...reference, verdict: "request-changes", body: " ", comments: [] }) + .pipe(Effect.flip); + + assert.equal(error.detail, "Requesting changes needs a comment."); + assert.deepStrictEqual(prCalls("review"), []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("approves without a body file and drops that pull request's cached reads", () => + Effect.gen(function* () { + onlyGitHubProject(); + hostAnswers({ + onWrite: () => "https://github.com/octocat/example-app/pull/12#pullrequestreview-1\n", + }); + + const service = yield* PullRequestService.PullRequestService; + yield* service.detail(reference); + + const result = yield* service.submitReview({ + ...reference, + verdict: "approve", + body: "", + comments: [], + }); + + assert.equal( + result.url, + "https://github.com/octocat/example-app/pull/12#pullrequestreview-1", + ); + assert.deepStrictEqual(prCalls("review")[0]?.args, [ + "pr", + "review", + "12", + "--repo", + "octocat/example-app", + "--approve", + ]); + + yield* service.detail(reference); + expect(prCalls("view")).toHaveLength(2); + }).pipe(Effect.provide(layer)), + ); +}); + +describe("PullRequestService capabilities", () => { + const reference = pullRequestReference; + + it.effect("refuses what the host cannot do before it reaches the host", () => + Effect.gen(function* () { + onlyGitHubProject(); + + const service = yield* PullRequestService.PullRequestService; + const error = yield* service + .setThreadResolution({ ...reference, threadId: "PRRT_1", resolved: true }) + .pipe(Effect.flip); + + assert.equal(error.detail, "This host cannot resolve a review conversation."); + }).pipe( + Effect.provide( + layerWithCapabilities({ + ...GITHUB_PULL_REQUEST_CAPABILITIES, + review: { ...GITHUB_PULL_REQUEST_CAPABILITIES.review, resolve: false }, + }), + ), + ), + ); + + it.effect("refuses an action the host does not list", () => + Effect.gen(function* () { + onlyGitHubProject(); + + const service = yield* PullRequestService.PullRequestService; + const error = yield* service + .runAction({ ...reference, action: "update-branch" }) + .pipe(Effect.flip); + + assert.equal(error.detail, "This host cannot update branch a pull request."); + }).pipe( + Effect.provide( + layerWithCapabilities({ + ...GITHUB_PULL_REQUEST_CAPABILITIES, + actions: ["merge", "close"], + }), + ), + ), + ); }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 2d6ead89e..193e06a11 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -3,49 +3,121 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Result from "effect/Result"; import { ProjectId, PullRequestServiceError, type OrchestrationProjectShell, + type PullRequestActionInput, + type PullRequestActionResult, + type PullRequestActivity, + type PullRequestActivityInput, + type PullRequestCapabilities, + type PullRequestCommentInput, + type PullRequestCommentResult, + type PullRequestCommentUpdateInput, + type PullRequestDetail, + type PullRequestDetailInput, + type PullRequestDiffInput, + type PullRequestDiffResult, type PullRequestListEntry, + type PullRequestListEntryOrigin, type PullRequestListInput, type PullRequestListProjectError, - type PullRequestListProjectErrorReason, type PullRequestListResult, type PullRequestListState, + type PullRequestMergeMethod, + type PullRequestReactionInput, + type PullRequestRef, + type PullRequestReviewerCandidateList, + type PullRequestReviewerRequestInput, + type PullRequestReviewInput, + type PullRequestReviewResult, + type PullRequestThreadReplyInput, + type PullRequestThreadResolutionInput, + type PullRequestUpdateInput, + type SourceControlProviderKind, } from "@threadlines/contracts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import { - findAuthenticatedGitHubAccount, - parseGitHubAuthStatus, -} from "../sourceControl/gitHubAuthStatus.ts"; + changeRequestRepositoryName, + toChangeRequestProviderKind, +} from "@threadlines/shared/sourceControl"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { - decodeGitHubPullRequestListJson, - formatGitHubPullRequestListDecodeError, - GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD, - GITHUB_PULL_REQUEST_LIST_FIELDS, - type GitHubPullRequestListRow, -} from "./gitHubPullRequestList.ts"; - -const GITHUB_HOST = "github.com"; + PullRequestProviderRegistry, + type ProviderAuthoredChangeRequest, + type ProviderChangeRequest, + type ProviderChangeRequestDetail, + type ProviderRepositoryAccess, + type PullRequestProviderApi, + type PullRequestProviderError, +} from "./PullRequestProvider.ts"; + const PROJECT_CONCURRENCY = 4; const OPEN_LIST_LIMIT = 50; const SETTLED_LIST_LIMIT = 30; -/** The page refreshes on an interval, so a short shared cache keeps `gh` off the host. */ +/** The page refreshes on an interval, so a short shared cache keeps the host quiet. */ const LIST_CACHE_TTL = Duration.seconds(30); const LIST_CACHE_CAPACITY = 32; /** The signed-in account changes far more rarely than the listings do. */ const VIEWER_CACHE_TTL = Duration.minutes(10); const VIEWER_CACHE_CAPACITY = 4; +/** One open pull request is read far more often than it changes. */ +const DETAIL_CACHE_TTL = Duration.seconds(15); +const ACTIVITY_CACHE_TTL = Duration.seconds(15); +/** Patches are the expensive read and the slowest to change. */ +const DIFF_CACHE_TTL = Duration.seconds(60); +const PULL_REQUEST_CACHE_CAPACITY = 32; +/** Repository settings and access change far more rarely than a pull request. */ +const REPOSITORY_CACHE_TTL = Duration.minutes(10); +const REPOSITORY_CACHE_CAPACITY = 16; + +/** The one thing a caller can get wrong that is not the host's fault. */ +const FOREIGN_PULL_REQUEST_DETAIL = "Pull request is not in this workspace."; export interface PullRequestServiceShape { readonly list: ( input: PullRequestListInput, ) => Effect.Effect; + readonly detail: ( + input: PullRequestDetailInput, + ) => Effect.Effect; + readonly activity: ( + input: PullRequestActivityInput, + ) => Effect.Effect; + readonly diff: ( + input: PullRequestDiffInput, + ) => Effect.Effect; + readonly comment: ( + input: PullRequestCommentInput, + ) => Effect.Effect; + readonly runAction: ( + input: PullRequestActionInput, + ) => Effect.Effect; + readonly submitReview: ( + input: PullRequestReviewInput, + ) => Effect.Effect; + readonly replyToThread: ( + input: PullRequestThreadReplyInput, + ) => Effect.Effect; + readonly setThreadResolution: ( + input: PullRequestThreadResolutionInput, + ) => Effect.Effect; + readonly setReaction: ( + input: PullRequestReactionInput, + ) => Effect.Effect; + readonly update: (input: PullRequestUpdateInput) => Effect.Effect; + readonly updateComment: ( + input: PullRequestCommentUpdateInput, + ) => Effect.Effect; + readonly reviewerCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly requestReviewers: ( + input: PullRequestReviewerRequestInput, + ) => Effect.Effect; } export class PullRequestService extends Context.Service< @@ -53,17 +125,43 @@ export class PullRequestService extends Context.Service< PullRequestServiceShape >()("threadlines/pullRequest/PullRequestService") {} -/** One project the listing can read, already resolved to an `owner/name` repository. */ +/** One project a provider can read, already resolved to a host repository. */ interface PullRequestProject { readonly projectId: ProjectId; + readonly provider: SourceControlProviderKind; readonly title: string; readonly workspaceRoot: string; readonly repository: string; + /** + * The remote itself, host and all. Two projects on one remote answer with the + * same rows, and this is what says so: `repository` cannot, because Azure + * DevOps names a repository by its own name and two projects of one + * organisation may each have a `tools`. + */ + readonly remoteKey: string; +} + +/** + * Where one call runs: the project whose checkout the host tool is run in, the + * provider that speaks for its host, and the repository the call addresses. + * That repository is the project's own remote for everything the workspace + * points at, and someone else's for a pull request the viewer opened elsewhere. + */ +interface PullRequestTarget { + readonly project: PullRequestProject; + readonly provider: PullRequestProviderApi; + readonly repository: string; } interface PullRequestListCacheKey { readonly state: PullRequestListState; readonly projectId?: ProjectId; + /** + * Part of the key rather than a filter over it: a listing without the + * viewer's own outside work is a different answer, and sharing one entry + * would hand whichever caller asked second the other one's rows. + */ + readonly includeAuthored: boolean; } /** What one project contributed to a listing: its rows, or the reason it failed. */ @@ -72,114 +170,207 @@ interface PullRequestProjectRead { readonly error: PullRequestListProjectError | null; } -const listCacheKey = (key: PullRequestListCacheKey) => `${key.state}|${key.projectId ?? "*"}`; +/** + * Identifies one pull request inside the caches keyed per pull request. The + * repository is part of it because one project reads pull requests from more + * than one repository: numbers collide across repositories, and the project + * alone would serve one of them the other's answer. + */ +interface PullRequestCacheKey { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; +} + +/** The repository's own settings, which are per repository and not per project. */ +interface RepositoryCacheKey { + readonly projectId: ProjectId; + readonly repository: string; +} + +const listCacheKey = (key: PullRequestListCacheKey) => + `${key.state}|${key.includeAuthored ? "1" : "0"}|${key.projectId ?? "*"}`; + +const pullRequestCacheKey = (key: PullRequestCacheKey) => + `${key.projectId}|${key.repository.toLowerCase()}|${key.number}`; + +/** + * Inverse of {@link pullRequestCacheKey}; only ever reads keys it produced. Read + * from the right, since no host allows a `|` in a repository name but nothing + * promises a project id has none. + */ +function parsePullRequestCacheKey(key: string): PullRequestCacheKey { + const numberIndex = key.lastIndexOf("|"); + const repositoryIndex = key.lastIndexOf("|", numberIndex - 1); + return { + projectId: ProjectId.make(key.slice(0, repositoryIndex)), + repository: key.slice(repositoryIndex + 1, numberIndex), + number: Number(key.slice(numberIndex + 1)), + }; +} + +const repositoryCacheKey = (key: RepositoryCacheKey) => + `${key.projectId}|${key.repository.toLowerCase()}`; + +/** Inverse of {@link repositoryCacheKey}; read from the right for the same reason. */ +function parseRepositoryCacheKey(key: string): RepositoryCacheKey { + const separatorIndex = key.lastIndexOf("|"); + return { + projectId: ProjectId.make(key.slice(0, separatorIndex)), + repository: key.slice(separatorIndex + 1), + }; +} /** Inverse of {@link listCacheKey}; only ever reads keys that function produced. */ function parseListCacheKey(key: string): PullRequestListCacheKey { - const separatorIndex = key.indexOf("|"); - const rawState = key.slice(0, separatorIndex); - const rawProjectId = key.slice(separatorIndex + 1); + const stateIndex = key.indexOf("|"); + const authoredIndex = key.indexOf("|", stateIndex + 1); + const rawState = key.slice(0, stateIndex); + const rawProjectId = key.slice(authoredIndex + 1); const state: PullRequestListState = rawState === "merged" ? "merged" : rawState === "closed" ? "closed" : "open"; return { state, + includeAuthored: key.slice(stateIndex + 1, authoredIndex) === "1", ...(rawProjectId === "*" ? {} : { projectId: ProjectId.make(rawProjectId) }), }; } +/** The viewer cache is keyed by both, since each host signs in on its own. */ +const viewerCacheKey = (provider: SourceControlProviderKind, cwd: string) => `${provider}|${cwd}`; + +function parseViewerCacheKey(key: string): { + readonly provider: SourceControlProviderKind; + readonly cwd: string; +} { + const separatorIndex = key.indexOf("|"); + return { + provider: key.slice(0, separatorIndex) as SourceControlProviderKind, + cwd: key.slice(separatorIndex + 1), + }; +} + /** - * Only workspace projects with a resolved GitHub `owner/name` can be listed. - * Everything else is skipped silently: a Bitbucket project or a general chat is - * not a failure the user needs to see. + * Only workspace projects on a host this build has a provider for, with a + * repository the host can be asked about, can be read. Everything else is + * skipped silently: a general chat or a host with no provider is not a failure + * the user needs. */ -function toPullRequestProject(project: OrchestrationProjectShell): PullRequestProject | null { +function toPullRequestProject( + project: OrchestrationProjectShell, + registry: PullRequestProviderRegistry["Service"], +): PullRequestProject | null { if (project.kind === "general-chat") { return null; } const identity = project.repositoryIdentity; - if (!identity || identity.provider !== "github") { + const provider = toChangeRequestProviderKind(identity?.provider); + if (provider === null || registry.get(provider) === null) { return null; } - const owner = identity.owner?.trim() ?? ""; - const name = identity.name?.trim() ?? ""; - if (owner.length === 0 || name.length === 0) { + const repository = changeRequestRepositoryName(identity); + if (repository === null) { return null; } return { projectId: project.id, + provider, title: project.title, workspaceRoot: project.workspaceRoot, - repository: `${owner}/${name}`, + repository, + remoteKey: identity?.canonicalKey.trim().toLowerCase() ?? `${provider}|${repository}`, }; } /** - * One read per repository. A checkout and its worktrees are separate projects - * pointing at the same remote, and the host would answer each of them with the - * same rows. The first project keeps the seat; GitHub repository names are - * case-insensitive, so the key is too. + * One read per remote. A checkout and its worktrees are separate projects + * pointing at the same one, and the host would answer each of them with the + * same rows. The first project keeps the seat; remotes are recorded + * case-insensitively, so the key is too. */ -function dedupeProjectsByRepository( +function dedupeProjectsByRemote( projects: ReadonlyArray, ): ReadonlyArray { - const byRepository = new Map(); + const byRemote = new Map(); for (const project of projects) { - const key = project.repository.toLowerCase(); - if (!byRepository.has(key)) { - byRepository.set(key, project); + if (!byRemote.has(project.remoteKey)) { + byRemote.set(project.remoteKey, project); } } - return [...byRepository.values()]; + return [...byRemote.values()]; } -/** Turns a `gh` failure into the reason the page renders an action for. */ -function classifyPullRequestListFailure(detail: string): PullRequestListProjectErrorReason { - const lower = detail.toLowerCase(); - if ( - lower.includes("not available on path") || - lower.includes("command not found") || - lower.includes("enoent") - ) { - return "missing-tool"; - } - if ( - lower.includes("not logged in") || - lower.includes("not authenticated") || - lower.includes("authentication") || - lower.includes("auth login") - ) { - return "unauthenticated"; - } - if (lower.includes("rate limit")) { - return "rate-limited"; +/** + * One project per host: a host's account-wide search answers the same rows + * whichever of its checkouts it is run from, so only the first is asked. + */ +function firstProjectPerProvider( + projects: ReadonlyArray, +): ReadonlyArray { + const byProvider = new Map(); + for (const project of projects) { + if (!byProvider.has(project.provider)) { + byProvider.set(project.provider, project); + } } - return "failed"; + return [...byProvider.values()]; +} + +/** One repository on one host. Repository names are case-insensitive, so this is too. */ +const repositoryScopeKey = (provider: SourceControlProviderKind, repository: string) => + `${provider}|${repository.trim().toLowerCase()}`; + +/** One pull request on one host, which is what says two reads found the same one. */ +const listRowKey = (provider: SourceControlProviderKind, repository: string, number: number) => + `${repositoryScopeKey(provider, repository)}|${number}`; + +/** + * Whether an authored row is news. A repository the workspace already read + * answers for its own rows, whatever the search says about it, and a row a + * workspace read already carries is one pull request found twice. + */ +function keepAuthoredRow(input: { + readonly row: ProviderAuthoredChangeRequest; + readonly anchor: PullRequestProject; + readonly covered: ReadonlySet; + readonly seen: ReadonlySet; +}): boolean { + const { provider } = input.anchor; + return ( + !input.covered.has(repositoryScopeKey(provider, input.row.repository)) && + !input.seen.has(listRowKey(provider, input.row.repository, input.row.number)) + ); } -function listFieldsFor(state: PullRequestListState): string { - return state === "open" - ? [...GITHUB_PULL_REQUEST_LIST_FIELDS, GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD].join(",") - : GITHUB_PULL_REQUEST_LIST_FIELDS.join(","); +/** Logins compare case-insensitively; an unknown viewer matches nothing. */ +function viewerMatcher(viewer: string | null): (login: string) => boolean { + const viewerLogin = viewer?.trim().toLowerCase() ?? ""; + return (login) => viewerLogin.length > 0 && login.toLowerCase() === viewerLogin; } function toEntry(input: { readonly project: PullRequestProject; - readonly row: GitHubPullRequestListRow; + readonly row: ProviderChangeRequest; readonly viewer: string | null; + readonly origin: PullRequestListEntryOrigin; + /** + * Where the row actually lives, when that is not the project's own remote. + * Such a row borrows the project only for its checkout, so it says which + * repository it is on where a workspace row says which project it is in. + */ + readonly repository?: string; }): PullRequestListEntry { - const { project, row, viewer } = input; - const viewerLogin = viewer?.trim().toLowerCase() ?? ""; - const matchesViewer = (login: string) => - viewerLogin.length > 0 && login.toLowerCase() === viewerLogin; + const { project, row } = input; + const matchesViewer = viewerMatcher(input.viewer); return { - provider: "github", + provider: project.provider, projectId: project.projectId, - projectTitle: project.title, - repository: project.repository, + projectTitle: input.repository ?? project.title, + repository: input.repository ?? project.repository, number: row.number, title: row.title, url: row.url, @@ -197,13 +388,94 @@ function toEntry(input: { ...(row.reviewDecision === undefined ? {} : { reviewDecision: row.reviewDecision }), ...(row.checksState === undefined ? {} : { checksState: row.checksState }), labels: row.labels, + origin: input.origin, + }; +} + +function toDetail(input: { + readonly target: PullRequestTarget; + readonly row: ProviderChangeRequestDetail; + readonly viewer: string | null; + readonly repository: ProviderRepositoryAccess; + readonly capabilities: PullRequestCapabilities; +}): PullRequestDetail { + const { project } = input.target; + const { row } = input; + const matchesViewer = viewerMatcher(input.viewer); + const viewerIsAuthor = row.author !== null && matchesViewer(row.author.login); + // A host refuses a review of your own pull request, and an unknown viewer + // could be anyone, so neither may review. + const viewerKnown = (input.viewer?.trim().length ?? 0) > 0; + const defaultBranch = input.repository.defaultBranch; + + return { + provider: project.provider, + projectId: project.projectId, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + repository: input.target.repository, + number: row.number, + title: row.title, + body: row.body, + url: row.url, + author: row.author, + state: row.state, + isDraft: row.isDraft, + mergeability: row.mergeability, + additions: row.additions, + deletions: row.deletions, + changedFiles: row.changedFiles, + headBranch: row.headBranch, + baseBranch: row.baseBranch, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + mergedAt: row.mergedAt, + closedAt: row.closedAt, + viewerIsAuthor, + ...(row.reviewDecision === undefined ? {} : { reviewDecision: row.reviewDecision }), + reviewers: row.reviewers, + labels: row.labels, + checks: row.checks, + ...(row.checksState === undefined ? {} : { checksState: row.checksState }), + viewer: { + canWrite: input.repository.canWrite, + canReview: viewerKnown && !viewerIsAuthor, + // A host lets the author close, reopen and rewrite their own pull + // request without any rights over the repository it is aimed at. + canManage: input.repository.canWrite || viewerIsAuthor, + }, + mergeMethods: input.repository.mergeMethods, + // What the host supports in general, narrowed to what this repository + // actually allows, so the client never offers a merge the host refuses. + capabilities: { ...input.capabilities, mergeMethods: input.repository.mergeMethods }, + baseComparison: row.baseComparison, + behindBy: row.behindBy, + autoMergeEnabled: row.autoMergeEnabled, + isStacked: defaultBranch !== null && row.baseBranch !== defaultBranch, + defaultBranch, }; } export const make = Effect.fn("makePullRequestService")(function* () { - const github = yield* GitHubCli.GitHubCli; + const registry = yield* PullRequestProviderRegistry; const projections = yield* ProjectionSnapshotQuery; + /** Every provider failure reaches the client as the host's own sentence. */ + const asServiceError = (operation: string) => (error: PullRequestProviderError) => + new PullRequestServiceError({ operation, detail: error.detail }); + + /** Refuses a call the host does not support before anything runs. */ + const requireCapability = (input: { + readonly operation: string; + readonly allowed: boolean; + readonly detail: string; + }) => + input.allowed + ? Effect.void + : Effect.fail( + new PullRequestServiceError({ operation: input.operation, detail: input.detail }), + ); + const readProjects = (projectId: ProjectId | undefined) => projections.getShellSnapshot().pipe( Effect.mapError( @@ -214,70 +486,31 @@ export const make = Effect.fn("makePullRequestService")(function* () { if (projectId !== undefined && project.id !== projectId) { return []; } - const target = toPullRequestProject(project); + const target = toPullRequestProject(project, registry); return target === null ? [] : [target]; }), ), ); /** - * `gh auth status` is the only place the signed-in login is available, and it - * needs a working directory like every other `gh` call, so the cache is keyed - * by the one it ran in. A host we cannot read leaves the viewer unknown - * rather than failing the listing. + * The signed-in login, which is what the viewer flags compare against. A host + * we cannot read leaves the viewer unknown rather than failing the listing. */ const viewerCache = yield* Cache.make({ capacity: VIEWER_CACHE_CAPACITY, timeToLive: VIEWER_CACHE_TTL, - lookup: (cwd: string) => - github.execute({ cwd, args: ["auth", "status", "--json", "hosts"] }).pipe( - Effect.map((output): string | null => { - const status = parseGitHubAuthStatus(output.stdout); - const account = findAuthenticatedGitHubAccount( - status.accounts.filter((entry) => entry.host === GITHUB_HOST), - ); - return account?.account ?? null; - }), - Effect.catch(() => Effect.succeed(null)), - ), + lookup: (key: string) => + Effect.suspend(() => { + const { provider, cwd } = parseViewerCacheKey(key); + const host = registry.get(provider); + return host === null + ? Effect.succeed(null) + : host.getViewer({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + }), }); - const readProjectRows = (project: PullRequestProject, state: PullRequestListState) => - github - .execute({ - cwd: project.workspaceRoot, - args: [ - "pr", - "list", - "--repo", - project.repository, - "--state", - state, - "--limit", - String(state === "open" ? OPEN_LIST_LIMIT : SETTLED_LIST_LIMIT), - "--json", - listFieldsFor(state), - ], - }) - .pipe( - Effect.flatMap((output) => { - const raw = output.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed>([]); - } - - const decoded = decodeGitHubPullRequestListJson(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubCli.GitHubCliError({ - operation: "pullRequests.list", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubPullRequestListDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - }), - ); + const readViewer = (project: PullRequestProject) => + Cache.get(viewerCache, viewerCacheKey(project.provider, project.workspaceRoot)); /** A project that fails becomes one error entry; the other projects still return. */ const readProject = (input: { @@ -285,43 +518,162 @@ export const make = Effect.fn("makePullRequestService")(function* () { readonly state: PullRequestListState; readonly viewer: string | null; }) => - readProjectRows(input.project, input.state).pipe( - Effect.map((rows): PullRequestProjectRead => ({ - entries: rows.map((row) => toEntry({ project: input.project, row, viewer: input.viewer })), - error: null, - })), - Effect.catch((error) => - Effect.succeed({ - entries: [], - error: { - projectId: input.project.projectId, - projectTitle: input.project.title, - repository: input.project.repository, - reason: classifyPullRequestListFailure(error.detail), - detail: error.detail, - }, + Effect.suspend(() => { + const host = registry.get(input.project.provider); + if (host === null) { + return Effect.succeed({ entries: [], error: null }); + } + return host + .listChangeRequests({ + cwd: input.project.workspaceRoot, + repository: input.project.repository, + state: input.state, + limit: input.state === "open" ? OPEN_LIST_LIMIT : SETTLED_LIST_LIMIT, + }) + .pipe( + Effect.map((rows): PullRequestProjectRead => ({ + entries: rows.map((row) => + toEntry({ project: input.project, row, viewer: input.viewer, origin: "workspace" }), + ), + error: null, + })), + Effect.catch((error) => + Effect.succeed({ + entries: [], + error: { + projectId: input.project.projectId, + projectTitle: input.project.title, + repository: input.project.repository, + reason: error.reason, + detail: error.detail, + }, + }), + ), + ); + }); + + /** + * The viewer's own pull requests anywhere on one host, whether or not the + * workspace points at the repository they are on. The anchor project is only + * the checkout the host's tool runs in; a row it finds keeps that project so + * a later read has somewhere to run, and names its own repository. + * + * Anything the workspace already read is dropped: the same pull request twice + * would be two rows of one thing, one of them without its project. + */ + const readAuthored = (input: { + readonly anchor: PullRequestProject; + readonly state: PullRequestListState; + readonly covered: ReadonlySet; + readonly seen: ReadonlySet; + }) => + Effect.suspend(() => { + const empty: PullRequestProjectRead = { entries: [], error: null }; + const host = registry.get(input.anchor.provider); + const search = host?.listAuthoredChangeRequests; + if (search === undefined) { + return Effect.succeed(empty); + } + return readViewer(input.anchor).pipe( + Effect.flatMap((viewer) => { + // Without a login there is nobody to search for; the workspace rows + // still stand, which is what they did before this existed. + if (viewer === null || viewer.trim().length === 0) { + return Effect.succeed(empty); + } + return search({ + cwd: input.anchor.workspaceRoot, + viewer, + state: input.state, + limit: input.state === "open" ? OPEN_LIST_LIMIT : SETTLED_LIST_LIMIT, + }).pipe( + Effect.map((rows): PullRequestProjectRead => ({ + entries: rows.flatMap((row) => + keepAuthoredRow({ + row, + anchor: input.anchor, + covered: input.covered, + seen: input.seen, + }) + ? [ + toEntry({ + project: input.anchor, + row, + viewer, + origin: "authored", + repository: row.repository, + }), + ] + : [], + ), + error: null, + })), + Effect.catch((error) => + Effect.succeed({ + entries: [], + error: { + projectId: input.anchor.projectId, + projectTitle: input.anchor.title, + // The search is not about any one repository, so there is + // none to name in the notice it fails into. + repository: null, + reason: error.reason, + detail: error.detail, + }, + }), + ), + ); }), - ), - ); + ); + }); const loadList = Effect.fn("PullRequestService.load")(function* (key: PullRequestListCacheKey) { - const projects = dedupeProjectsByRepository(yield* readProjects(key.projectId)); + const projects = dedupeProjectsByRemote(yield* readProjects(key.projectId)); const first = projects[0]; if (first === undefined) { return { viewer: null, entries: [], errors: [] } satisfies PullRequestListResult; } - const viewer = yield* Cache.get(viewerCache, first.workspaceRoot); + // The viewer is read per project, because each host signs in on its own and + // a workspace can hold projects on several. The cache is keyed by host and + // checkout, so projects that share one share the read. + const viewer = yield* readViewer(first); const results = yield* Effect.forEach( projects, - (project) => readProject({ project, state: key.state, viewer }), + (project) => + readViewer(project).pipe( + Effect.flatMap((projectViewer) => + readProject({ project, state: key.state, viewer: projectViewer }), + ), + ), + { concurrency: PROJECT_CONCURRENCY }, + ); + + const entries = results.flatMap((result) => result.entries); + const errors = results.flatMap((result) => (result.error === null ? [] : [result.error])); + if (!key.includeAuthored) { + return { viewer, entries, errors } satisfies PullRequestListResult; + } + + const covered = new Set( + projects.map((project) => repositoryScopeKey(project.provider, project.repository)), + ); + const seen = new Set( + entries.map((entry) => listRowKey(entry.provider, entry.repository, entry.number)), + ); + const authored = yield* Effect.forEach( + firstProjectPerProvider(projects), + (anchor) => readAuthored({ anchor, state: key.state, covered, seen }), { concurrency: PROJECT_CONCURRENCY }, ); return { viewer, - entries: results.flatMap((result) => result.entries), - errors: results.flatMap((result) => (result.error === null ? [] : [result.error])), + entries: [...entries, ...authored.flatMap((result) => result.entries)], + errors: [ + ...errors, + ...authored.flatMap((result) => (result.error === null ? [] : [result.error])), + ], } satisfies PullRequestListResult; }); @@ -331,17 +683,513 @@ export const make = Effect.fn("makePullRequestService")(function* () { lookup: (key: string) => loadList(parseListCacheKey(key)), }); + /** + * Where a per-pull-request call runs: the project has to be one this build can + * read pull requests from, since its checkout is what the host's tool is run + * in. The repository rides along from the caller rather than being taken from + * the project, because a pull request the viewer opened on a repository + * nobody here has checked out is read through a project on the same host. + */ + const requireTarget = (operation: string, projectId: ProjectId, repository: string) => + projections.getShellSnapshot().pipe( + Effect.mapError((error) => new PullRequestServiceError({ operation, detail: error.message })), + Effect.flatMap((snapshot) => { + const found = snapshot.projects.find((candidate) => candidate.id === projectId); + const project = found === undefined ? null : toPullRequestProject(found, registry); + const provider = project === null ? null : registry.get(project.provider); + return project === null || provider === null + ? Effect.fail( + new PullRequestServiceError({ operation, detail: FOREIGN_PULL_REQUEST_DETAIL }), + ) + : Effect.succeed({ + project, + provider, + repository: repository.trim(), + }); + }), + ); + + /** The target one reference names, guarded before it costs a host call. */ + const resolveReference = (operation: string, reference: PullRequestRef) => + requireTarget(operation, reference.projectId, reference.repository); + + /** Where a provider call runs, for a target already resolved. */ + const repositoryRef = (target: PullRequestTarget) => ({ + cwd: target.project.workspaceRoot, + repository: target.repository, + }); + + /** + * The repository's own settings: whether the viewer may push, how the host + * lets a pull request land, and what its default branch is. Keyed by the + * repository as well as the project, since one project reads more than one. + */ + const repositoryCache = yield* Cache.make({ + capacity: REPOSITORY_CACHE_CAPACITY, + timeToLive: REPOSITORY_CACHE_TTL, + lookup: (key: string) => + Effect.suspend(() => { + const { projectId, repository } = parseRepositoryCacheKey(key); + return requireTarget("repository", projectId, repository).pipe( + Effect.flatMap((target) => + target.provider + .getRepositoryAccess(repositoryRef(target)) + .pipe(Effect.mapError(asServiceError("repository"))), + ), + ); + }), + }); + + const readRepositoryAccess = (target: PullRequestTarget) => + Cache.get( + repositoryCache, + repositoryCacheKey({ projectId: target.project.projectId, repository: target.repository }), + ); + + const loadDetail = Effect.fn("PullRequestService.loadDetail")(function* ( + key: PullRequestCacheKey, + ) { + const target = yield* requireTarget("detail", key.projectId, key.repository); + const viewer = yield* readViewer(target.project); + const repository = yield* readRepositoryAccess(target); + const row = yield* target.provider + .getChangeRequest({ ...repositoryRef(target), number: key.number }) + .pipe(Effect.mapError(asServiceError("detail"))); + return toDetail({ + target, + row, + viewer, + repository, + capabilities: target.provider.capabilities, + }); + }); + + const loadActivity = Effect.fn("PullRequestService.loadActivity")(function* ( + key: PullRequestCacheKey, + ) { + const target = yield* requireTarget("activity", key.projectId, key.repository); + return yield* target.provider + .getChangeRequestActivity({ ...repositoryRef(target), number: key.number }) + .pipe(Effect.mapError(asServiceError("activity"))); + }); + + const loadDiff = Effect.fn("PullRequestService.loadDiff")(function* (key: PullRequestCacheKey) { + const target = yield* requireTarget("diff", key.projectId, key.repository); + yield* requireCapability({ + operation: "diff", + allowed: target.provider.capabilities.diff, + detail: "This host cannot show a diff.", + }); + return yield* target.provider + .getDiff({ ...repositoryRef(target), number: key.number }) + .pipe(Effect.mapError(asServiceError("diff"))); + }); + + const detailCache = yield* Cache.make({ + capacity: PULL_REQUEST_CACHE_CAPACITY, + timeToLive: DETAIL_CACHE_TTL, + lookup: (key: string) => loadDetail(parsePullRequestCacheKey(key)), + }); + + const activityCache = yield* Cache.make({ + capacity: PULL_REQUEST_CACHE_CAPACITY, + timeToLive: ACTIVITY_CACHE_TTL, + lookup: (key: string) => loadActivity(parsePullRequestCacheKey(key)), + }); + + const diffCache = yield* Cache.make({ + capacity: PULL_REQUEST_CACHE_CAPACITY, + timeToLive: DIFF_CACHE_TTL, + lookup: (key: string) => loadDiff(parsePullRequestCacheKey(key)), + }); + + /** Validates the reference, then serves the read through its cache. */ + const cachedRead = (input: { + readonly operation: string; + readonly cache: Cache.Cache; + readonly reference: PullRequestRef; + readonly force: boolean; + /** What else a forced read drops, for a read that folds in another cache. */ + readonly alsoInvalidate?: (target: PullRequestTarget) => Effect.Effect; + }) => + resolveReference(input.operation, input.reference).pipe( + Effect.flatMap((target) => { + const key = pullRequestCacheKey({ + projectId: target.project.projectId, + repository: target.repository, + number: input.reference.number, + }); + return ( + input.force + ? Cache.invalidate(input.cache, key).pipe( + Effect.andThen(input.alsoInvalidate?.(target) ?? Effect.void), + ) + : Effect.void + ).pipe(Effect.andThen(Cache.get(input.cache, key))); + }), + ); + + /** Everything cached about one pull request's own reads. */ + const invalidatePullRequest = (target: PullRequestTarget, number: number) => + Effect.gen(function* () { + const key = pullRequestCacheKey({ + projectId: target.project.projectId, + repository: target.repository, + number, + }); + yield* Cache.invalidate(detailCache, key); + yield* Cache.invalidate(activityCache, key); + }); + + /** + * The method the host will accept: the caller's when the repository allows it, + * the repository's first allowed one otherwise. Refusing here keeps a + * disallowed merge from reaching the host at all. + */ + const resolveMergeMethod = ( + target: PullRequestTarget, + requested: PullRequestMergeMethod | undefined, + ) => + readRepositoryAccess(target).pipe( + Effect.flatMap((repository) => { + const method = requested ?? repository.mergeMethods[0]; + if (method === undefined) { + return Effect.fail( + new PullRequestServiceError({ + operation: "runAction", + detail: "This repository does not allow any merge method.", + }), + ); + } + return repository.mergeMethods.includes(method) + ? Effect.succeed(method) + : Effect.fail( + new PullRequestServiceError({ + operation: "runAction", + detail: `This repository does not allow a ${method} merge.`, + }), + ); + }), + ); + + /** A forced detail read is also how the repository's settings are refreshed. */ + const readDetail = (input: PullRequestDetailInput) => + cachedRead({ + operation: "detail", + cache: detailCache, + reference: input, + force: input.force === true, + alsoInvalidate: (target) => + Cache.invalidate( + repositoryCache, + repositoryCacheKey({ + projectId: target.project.projectId, + repository: target.repository, + }), + ), + }); + + /** A write that changes what a list row says drops every cached listing. */ + const invalidateAfterWrite = (input: { + readonly target: PullRequestTarget; + readonly number: number; + readonly lists: boolean; + }) => + invalidatePullRequest(input.target, input.number).pipe( + Effect.andThen(input.lists ? Cache.invalidateAll(listCache) : Effect.void), + ); + return PullRequestService.of({ list: (input) => Effect.suspend(() => { const key = listCacheKey({ state: input.state, + includeAuthored: input.includeAuthored !== false, ...(input.projectId === undefined ? {} : { projectId: input.projectId }), }); return (input.force === true ? Cache.invalidate(listCache, key) : Effect.void).pipe( Effect.andThen(Cache.get(listCache, key)), ); }), + detail: readDetail, + activity: (input) => + cachedRead({ + operation: "activity", + cache: activityCache, + reference: input, + force: input.force === true, + }), + diff: (input) => + cachedRead({ + operation: "diff", + cache: diffCache, + reference: input, + force: input.force === true, + }), + comment: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("comment", input); + yield* requireCapability({ + operation: "comment", + allowed: target.provider.capabilities.comment, + detail: "This host cannot take a comment.", + }); + + const result = yield* target.provider + .comment({ ...repositoryRef(target), number: input.number, body: input.body }) + .pipe(Effect.mapError(asServiceError("comment"))); + + // The comment is now part of this pull request and of every listing's + // updated time, so nothing cached about it is still true. + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + return result; + }), + runAction: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("runAction", input); + const capabilities = target.provider.capabilities; + yield* requireCapability({ + operation: "runAction", + allowed: capabilities.actions.includes(input.action), + detail: `This host cannot ${input.action.replace(/-/g, " ")} a pull request.`, + }); + + const needsMergeMethod = input.action === "merge" || input.action === "enable-auto-merge"; + const mergeMethod = needsMergeMethod + ? yield* resolveMergeMethod(target, input.mergeMethod) + : undefined; + + if (input.action === "update-branch" && input.updateMethod !== undefined) { + yield* requireCapability({ + operation: "runAction", + allowed: capabilities.updateMethods.includes(input.updateMethod), + detail: `This host cannot update a branch by ${input.updateMethod}.`, + }); + } + + yield* target.provider + .runAction({ + ...repositoryRef(target), + number: input.number, + action: input.action, + ...(mergeMethod === undefined ? {} : { mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), + ...(input.deleteBranch === undefined ? {} : { deleteBranch: input.deleteBranch }), + }) + .pipe(Effect.mapError(asServiceError("runAction"))); + + // The action moved the pull request's own state, which every listing + // renders, so nothing cached about it is still true. + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + + // Read back through the service's own path, so the answer and the cache + // the client reads next are the same fresh detail. + const detail = yield* readDetail({ + projectId: input.projectId, + repository: input.repository, + number: input.number, + }); + return { state: detail.state, isDraft: detail.isDraft } satisfies PullRequestActionResult; + }), + submitReview: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("submitReview", input); + const review = target.provider.capabilities.review; + yield* requireCapability({ + operation: "submitReview", + allowed: review.verdicts.includes(input.verdict), + detail: "This host cannot take that review verdict.", + }); + yield* requireCapability({ + operation: "submitReview", + allowed: input.comments.length === 0 || review.inlineComment, + detail: "This host cannot take comments on diff lines.", + }); + + const body = input.body.trim(); + // The host rejects these without anything to say; saying so here costs + // no round trip. A line comment is itself something to say. + if (body.length === 0 && input.comments.length === 0 && input.verdict !== "approve") { + return yield* Effect.fail( + new PullRequestServiceError({ + operation: "submitReview", + detail: + input.verdict === "request-changes" + ? "Requesting changes needs a comment." + : "A review comment needs a body.", + }), + ); + } + + const result = yield* target.provider + .submitReview({ + ...repositoryRef(target), + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(asServiceError("submitReview"))); + + // A verdict changes the review decision the list rows show. + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + return result; + }), + replyToThread: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("replyToThread", input); + yield* requireCapability({ + operation: "replyToThread", + allowed: target.provider.capabilities.review.reply, + detail: "This host cannot reply to a review conversation.", + }); + + yield* target.provider + .replyToThread({ + ...repositoryRef(target), + number: input.number, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(asServiceError("replyToThread"))); + + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + }), + setThreadResolution: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("setThreadResolution", input); + yield* requireCapability({ + operation: "setThreadResolution", + allowed: target.provider.capabilities.review.resolve, + detail: "This host cannot resolve a review conversation.", + }); + + yield* target.provider + .setThreadResolution({ + ...repositoryRef(target), + number: input.number, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(asServiceError("setThreadResolution"))); + + // Resolving changes the conversation, not what any list row says. + yield* invalidateAfterWrite({ target, number: input.number, lists: false }); + }), + setReaction: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("setReaction", input); + yield* requireCapability({ + operation: "setReaction", + allowed: target.provider.capabilities.reactions, + detail: "This host cannot take reactions.", + }); + + yield* target.provider + .setReaction({ + ...repositoryRef(target), + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(asServiceError("setReaction"))); + + yield* invalidateAfterWrite({ target, number: input.number, lists: false }); + }), + update: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("update", input); + yield* requireCapability({ + operation: "update", + allowed: target.provider.capabilities.edit.pullRequest, + detail: "This host cannot rewrite a pull request.", + }); + // A host asked to change nothing answers differently on each of them. + if (input.title === undefined && input.body === undefined) { + return yield* Effect.fail( + new PullRequestServiceError({ + operation: "update", + detail: "A change needs a title or a description.", + }), + ); + } + + yield* target.provider + .updateChangeRequest({ + ...repositoryRef(target), + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError(asServiceError("update"))); + + // The title is on every list row. + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + }), + updateComment: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("updateComment", input); + yield* requireCapability({ + operation: "updateComment", + allowed: target.provider.capabilities.edit.comment, + detail: "This host cannot rewrite a comment.", + }); + + yield* target.provider + .updateComment({ + ...repositoryRef(target), + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }) + .pipe(Effect.mapError(asServiceError("updateComment"))); + + yield* invalidateAfterWrite({ target, number: input.number, lists: false }); + }), + reviewerCandidates: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("reviewerCandidates", input); + yield* requireCapability({ + operation: "reviewerCandidates", + allowed: target.provider.capabilities.reviewers.listCandidates, + detail: "This host cannot list the people you may ask for a review.", + }); + + return yield* target.provider + .listReviewerCandidates({ ...repositoryRef(target), number: input.number }) + .pipe(Effect.mapError(asServiceError("reviewerCandidates"))); + }), + requestReviewers: (input) => + Effect.gen(function* () { + const target = yield* resolveReference("requestReviewers", input); + yield* requireCapability({ + operation: "requestReviewers", + allowed: target.provider.capabilities.reviewers.request, + detail: "This host cannot ask for a review.", + }); + if (input.reviewers.length === 0) { + return yield* Effect.fail( + new PullRequestServiceError({ + operation: "requestReviewers", + detail: "Name at least one reviewer.", + }), + ); + } + + yield* target.provider + .setReviewerRequest({ + ...repositoryRef(target), + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(asServiceError("requestReviewers"))); + + // A pending request is what the list rows call "review requested". + yield* invalidateAfterWrite({ target, number: input.number, lists: true }); + }), }); }); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequest.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequest.test.ts new file mode 100644 index 000000000..2d2085946 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequest.test.ts @@ -0,0 +1,161 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { + decodeAzureDevOpsPullRequestJson, + decodeAzureDevOpsPullRequestListJson, + decodeAzureDevOpsThreadsJson, +} from "./azureDevOpsPullRequest.ts"; + +const REST_URL = + "https://dev.azure.com/acme/_apis/git/repositories/11111111-2222-3333-4444-555555555555/pullRequests/7"; + +const pullRequest = (overrides: Record) => ({ + pullRequestId: 7, + title: "Tidy the toolbox", + status: "active", + sourceRefName: "refs/heads/feature/tidy", + targetRefName: "refs/heads/main", + creationDate: "2026-08-30T10:00:00Z", + url: REST_URL, + repository: { name: "tools", project: { name: "Platform" } }, + ...overrides, +}); + +const success = (result: Result.Result): A => { + assert.ok(Result.isSuccess(result), "expected the payload to decode"); + return result.success; +}; + +describe("decodeAzureDevOpsPullRequestListJson", () => { + it("strips the ref prefix, reads the status, and builds the browser url from the org", () => { + const rows = success( + decodeAzureDevOpsPullRequestListJson( + JSON.stringify([ + pullRequest({ pullRequestId: 1 }), + pullRequest({ + pullRequestId: 2, + status: "completed", + closedDate: "2026-08-31T10:00:00Z", + }), + pullRequest({ pullRequestId: 3, status: "abandoned" }), + // Too little to place, so it is skipped rather than carried unusable. + { pullRequestId: 4, title: "No branches", creationDate: "2026-08-30T10:00:00Z" }, + ]), + ), + ); + + assert.deepStrictEqual( + rows.map((row) => [row.number, row.state, row.headBranch, row.baseBranch, row.updatedAt]), + [ + [1, "open", "feature/tidy", "main", "2026-08-30T10:00:00Z"], + [2, "merged", "feature/tidy", "main", "2026-08-31T10:00:00Z"], + [3, "closed", "feature/tidy", "main", "2026-08-30T10:00:00Z"], + ], + ); + assert.equal(rows[0]?.url, "https://dev.azure.com/acme/Platform/_git/tools/pullrequest/1"); + }); + + it("prefers the web link Azure sends over one it would have to assemble", () => { + const rows = success( + decodeAzureDevOpsPullRequestListJson( + JSON.stringify([ + pullRequest({ _links: { web: { href: "https://dev.azure.com/acme/_git/tools/pr/7" } } }), + ]), + ), + ); + + assert.equal(rows[0]?.url, "https://dev.azure.com/acme/_git/tools/pr/7"); + }); +}); + +describe("decodeAzureDevOpsPullRequestJson", () => { + it("reads a reviewer's vote as a verdict and auto-complete from who armed it", () => { + const row = success( + decodeAzureDevOpsPullRequestJson( + JSON.stringify( + pullRequest({ + mergeStatus: "conflicts", + autoCompleteSetBy: { displayName: "Octo Cat" }, + reviewers: [ + { id: "guid-1", uniqueName: "hubot@acme.test", vote: 10 }, + { id: "guid-2", uniqueName: "monalisa@acme.test", vote: -10 }, + { id: "guid-3", uniqueName: "waiting@acme.test", vote: 0 }, + ], + }), + ), + ), + ); + + assert.equal(row?.mergeability, "conflicting"); + assert.equal(row?.autoMergeEnabled, true); + assert.deepStrictEqual( + row?.reviewers.map((reviewer) => [reviewer.id, reviewer.login, reviewer.state]), + [ + ["guid-1", "hubot@acme.test", "approved"], + ["guid-2", "monalisa@acme.test", "changes-requested"], + ["guid-3", "waiting@acme.test", "pending"], + ], + ); + // Only the reviewers still owing a verdict count as a request. + assert.deepStrictEqual(row?.reviewRequestedLogins, ["waiting@acme.test"]); + }); + + it("names the thread collection from the organization the REST url carries", () => { + const row = success(decodeAzureDevOpsPullRequestJson(JSON.stringify(pullRequest({})))); + + assert.equal( + row?.threadsUrl, + "https://dev.azure.com/acme/Platform/_apis/git/repositories/tools/pullRequests/7/threads", + ); + }); +}); + +describe("decodeAzureDevOpsThreadsJson", () => { + it("drops Azure's own notes and the deleted ones, and reads oldest first", () => { + const comments = success( + decodeAzureDevOpsThreadsJson( + JSON.stringify({ + value: [ + { + id: 1, + comments: [ + { + id: 1, + content: "Second", + publishedDate: "2026-08-31T10:05:00Z", + author: { uniqueName: "octocat@acme.test" }, + }, + { + id: 2, + content: "voted", + publishedDate: "2026-08-31T10:06:00Z", + commentType: "system", + }, + ], + }, + { + id: 2, + threadContext: { filePath: "/a.ts" }, + comments: [ + { id: 1, content: "First", publishedDate: "2026-08-31T10:00:00Z" }, + { id: 2, content: "Gone", publishedDate: "2026-08-31T10:01:00Z", isDeleted: true }, + ], + }, + { id: 3, isDeleted: true, comments: [{ id: 1, content: "Hidden" }] }, + ], + }), + "octocat@acme.test", + ), + ); + + assert.deepStrictEqual( + comments.map((comment) => [comment.id, comment.body, comment.viewerIsAuthor]), + [ + ["2:1", "First", false], + ["1:1", "Second", true], + ], + ); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequest.ts b/apps/server/src/pullRequest/azureDevOpsPullRequest.ts new file mode 100644 index 000000000..98a899723 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequest.ts @@ -0,0 +1,399 @@ +import type * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestComment, + type PullRequestMergeability, + type PullRequestReviewer, + type PullRequestState, +} from "@threadlines/contracts"; +import { decodeJsonResult } from "@threadlines/shared/schemaJson"; + +import { + azureDevOpsOrganizationBaseFromRestApiUrl, + azureDevOpsPullRequestWebUrl, +} from "../sourceControl/azureDevOpsPullRequests.ts"; + +type DecodeFailure = Cause.Cause; + +/** + * Azure's enums are decoded as plain strings and normalized here, in the same + * tolerant style as the other hosts. 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 AzureIdentitySchema = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + /** An email or UPN, which is what `az account show` reports for the viewer. */ + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + /** How `az repos pr reviewer` names one, when Azure carries it. */ + id: Schema.optional(Schema.NullOr(Schema.String)), + vote: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +const AzurePullRequestSchema = Schema.Struct({ + pullRequestId: Schema.Int, + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * Who armed auto-complete, which is all Azure says about it: the field + * carries an identity while the pull request is set to complete on its own, + * and Azure leaves it out entirely once nobody has. + */ + autoCompleteSetBy: Schema.optional(Schema.NullOr(AzureIdentitySchema)), + mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), + createdBy: Schema.optional(Schema.NullOr(AzureIdentitySchema)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(AzureIdentitySchema))), + // Required, and required to be non-empty: the wire contract will not carry a + // pull 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 AzureThreadSchema = 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(AzureIdentitySchema)), + publishedDate: Schema.optional(Schema.NullOr(Schema.String)), + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** `system` marks the notes Azure writes itself, which are events. */ + commentType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), +}); + +const AzureThreadPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); + +const AzureViewerSchema = Schema.Struct({ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const AzureRepositorySchema = Schema.Struct({ + defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** One decoded pull request, before the service attaches its project. */ +export interface AzureDevOpsPullRequestRow { + 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 its closing time + * stands in where there is one and its creation time otherwise. + */ + readonly updatedAt: string; + readonly closedAt: string | null; + readonly body: string; + readonly reviewRequestedLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** Where this pull request's threads live, when Azure said enough to say. */ + readonly threadsUrl: string | null; + /** Whether Azure is set to complete this on its own once its policies pass. */ + readonly autoMergeEnabled: boolean; +} + +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, +): PullRequestActor | null { + const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); + // Azure names no bot flag on the identities it hands back. + return login === null ? null : { login, isBot: false }; +} + +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"; + } +} + +/** + * Azure records a reviewer's verdict as a vote: 10 and 5 approve, -5 waits for + * the author, -10 rejects, and 0 means they have not voted yet. + */ +function toReviewerState(vote: number | null | undefined): PullRequestReviewer["state"] { + if (vote === undefined || vote === null || vote === 0) { + return "pending"; + } + if (vote > 0) { + return "approved"; + } + return vote <= -10 ? "changes-requested" : "commented"; +} + +/** + * 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 toRow( + raw: Schema.Schema.Type, +): AzureDevOpsPullRequestRow | null { + 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; + } + + const reviewers = (raw.reviewers ?? []).flatMap( + (reviewer): ReadonlyArray => { + const actor = toActor(reviewer); + return actor === null + ? [] + : [ + { + // Azure names an identity by an email or a guid, and takes either. + id: trimmed(reviewer.id) ?? actor.login, + kind: "user", + login: actor.login, + state: toReviewerState(reviewer.vote), + }, + ]; + }, + ); + const closedAt = trimmed(raw.closedDate); + + return { + number: raw.pullRequestId, + title: raw.title, + url, + author: toActor(raw.createdBy), + headBranch, + baseBranch, + state: toState(raw), + isDraft: raw.isDraft === true, + mergeability: toMergeability(raw.mergeStatus), + createdAt: raw.creationDate, + updatedAt: closedAt ?? raw.creationDate, + closedAt, + body: raw.description ?? "", + reviewRequestedLogins: reviewers + .filter((reviewer) => reviewer.state === "pending") + .map((reviewer) => reviewer.login), + reviewers, + threadsUrl: toThreadsUrl(raw), + autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodePullRequestEntry = Schema.decodeUnknownExit(AzurePullRequestSchema); +const decodePullRequest = decodeJsonResult(AzurePullRequestSchema); +const decodeThreadPage = decodeJsonResult(AzureThreadPageSchema); +const decodeThreadEntry = Schema.decodeUnknownExit(AzureThreadSchema); +const decodeViewer = decodeJsonResult(AzureViewerSchema); +const decodeRepository = decodeJsonResult(AzureRepositorySchema); + +/** Malformed entries are skipped rather than failing the batch. */ +export function decodeAzureDevOpsPullRequestListJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const rows: AzureDevOpsPullRequestRow[] = []; + for (const entry of payload.success) { + const decoded = decodePullRequestEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const row = toRow(decoded.value); + if (row !== null) { + rows.push(row); + } + } + return Result.succeed(rows); +} + +/** Null carries "Azure answered, but with too little to use". */ +export function decodeAzureDevOpsPullRequestJson( + raw: string, +): Result.Result { + const payload = decodePullRequest(raw); + return Result.isSuccess(payload) + ? Result.succeed(toRow(payload.success)) + : Result.fail(payload.failure); +} + +/** `az account show --query user` reports the viewer, whose name is an email. */ +export function decodeAzureDevOpsViewerJson( + raw: string, +): Result.Result { + const payload = decodeViewer(raw); + return Result.isSuccess(payload) + ? Result.succeed(trimmed(payload.success.user?.name)) + : Result.fail(payload.failure); +} + +export function decodeAzureDevOpsRepositoryJson( + raw: string, +): Result.Result { + const payload = decodeRepository(raw); + return Result.isSuccess(payload) + ? Result.succeed(trimmed(normalizeRefName(payload.success.defaultBranch ?? ""))) + : Result.fail(payload.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. Azure answers the whole collection in one + * response, with no cursor and no page to follow. + * + * A thread pinned to a file is a line remark, and this host cannot show a diff + * to pin it to, so it joins the conversation like any other. + */ +export function decodeAzureDevOpsThreadsJson( + raw: string, + viewer: string | null, +): Result.Result, DecodeFailure> { + const payload = decodeThreadPage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const viewerLogin = viewer?.toLowerCase() ?? null; + const comments: PullRequestComment[] = []; + for (const entry of payload.success.value) { + const decoded = decodeThreadEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const thread = decoded.value; + if (thread.isDeleted === true) { + continue; + } + 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; + } + const author = toActor(comment.author); + comments.push({ + id: `${thread.id}:${comment.id ?? 0}`, + kind: "issue-comment", + author, + body: comment.content ?? "", + createdAt: publishedDate, + url: null, + reviewState: null, + // Azure DevOps has no reaction on a pull request comment. + reactions: [], + viewerIsAuthor: viewerLogin !== null && author?.login.toLowerCase() === viewerLogin, + }); + } + } + return Result.succeed( + comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ); +} diff --git a/apps/server/src/pullRequest/bitbucketPullRequest.test.ts b/apps/server/src/pullRequest/bitbucketPullRequest.test.ts new file mode 100644 index 000000000..40d46904e --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequest.test.ts @@ -0,0 +1,266 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { + buildBitbucketReviewThreads, + decodeBitbucketCommentsJson, + decodeBitbucketConflictsJson, + decodeBitbucketDiffStatJson, + decodeBitbucketPullRequestPageJson, + decodeBitbucketRepositoryPermissionJson, + decodeBitbucketStatusesJson, +} from "./bitbucketPullRequest.ts"; + +const pullRequest = (overrides: Record) => ({ + id: 7, + title: "Tidy the toolbox", + state: "OPEN", + source: { branch: { name: "feature/tidy" } }, + destination: { branch: { name: "main" } }, + created_on: "2026-08-30T10:00:00.123456+00:00", + updated_on: "2026-08-31T10:00:00.123456+00:00", + links: { html: { href: "https://bitbucket.org/acme/tools/pull-requests/7" } }, + ...overrides, +}); + +const success = (result: Result.Result): A => { + assert.ok(Result.isSuccess(result), "expected the payload to decode"); + return result.success; +}; + +describe("decodeBitbucketPullRequestPageJson", () => { + it("normalizes the times, reads the states, and skips a malformed row", () => { + const page = success( + decodeBitbucketPullRequestPageJson( + JSON.stringify({ + values: [ + pullRequest({ id: 1 }), + pullRequest({ id: 2, state: "DECLINED" }), + pullRequest({ id: 3, state: "SUPERSEDED" }), + pullRequest({ id: 4, state: "MERGED" }), + { id: 5 }, + ], + next: "https://api.bitbucket.org/2.0/next", + }), + ), + ); + + assert.deepStrictEqual( + page.items.map((row) => [row.number, row.state]), + [ + [1, "open"], + [2, "closed"], + [3, "closed"], + [4, "merged"], + ], + ); + assert.equal(page.items[0]?.createdAt, "2026-08-30T10:00:00.123Z"); + assert.equal(page.next, "https://api.bitbucket.org/2.0/next"); + }); + + it("keeps the reviewers under the uuid a write takes, and reads a vote as a verdict", () => { + const page = success( + decodeBitbucketPullRequestPageJson( + JSON.stringify({ + values: [ + pullRequest({ + reviewers: [{ uuid: "{abc}", nickname: "hubot" }, { nickname: "nouuid" }], + participants: [ + { + user: { nickname: "hubot" }, + state: "changes_requested", + participated_on: "2026-08-31T09:00:00+00:00", + }, + { user: { nickname: "monalisa" }, state: null, approved: false }, + ], + }), + ], + }), + ), + ); + + assert.deepStrictEqual(page.items[0]?.reviewers, [{ id: "{abc}", login: "hubot" }]); + assert.deepStrictEqual( + page.items[0]?.reviews.map((review) => [review.author?.login, review.reviewState]), + [["hubot", "changes-requested"]], + ); + }); +}); + +describe("decodeBitbucketCommentsJson", () => { + it("leaves a line comment out of the conversation and drops deleted and unsent ones", () => { + const comments = success( + decodeBitbucketCommentsJson( + JSON.stringify({ + values: [ + { + id: 1, + content: { raw: "Looks good" }, + user: { nickname: "octocat" }, + created_on: "2026-08-31T10:00:00+00:00", + }, + { + id: 2, + content: { raw: "On this line" }, + created_on: "2026-08-31T10:05:00+00:00", + inline: { path: "a.ts", to: 12 }, + }, + { + id: 3, + content: { raw: "Gone" }, + created_on: "2026-08-31T10:10:00+00:00", + deleted: true, + }, + { + id: 4, + content: { raw: "Draft" }, + created_on: "2026-08-31T10:15:00+00:00", + pending: true, + }, + ], + }), + "octocat", + ), + ); + + assert.deepStrictEqual( + comments.comments.map((comment) => [comment.id, comment.viewerIsAuthor]), + [["1", true]], + ); + // Both the remark and the line comment stay unread, so a thread can be built. + assert.deepStrictEqual( + comments.entries.map((entry) => entry.id), + [1, 2], + ); + }); +}); + +describe("buildBitbucketReviewThreads", () => { + it("hangs a reply on the line comment it leads back to and reads the side from the line", () => { + const threads = buildBitbucketReviewThreads( + [ + { + id: 1, + content: { raw: "Removed line" }, + user: { nickname: "hubot" }, + created_on: "2026-08-31T10:00:00+00:00", + inline: { path: "a.ts", from: 12, outdated: true }, + resolution: { type: "resolution" }, + }, + { + id: 2, + content: { raw: "Agreed" }, + user: { nickname: "octocat" }, + created_on: "2026-08-31T10:05:00+00:00", + parent: { id: 1 }, + }, + { + id: 3, + content: { raw: "Added line" }, + created_on: "2026-08-31T10:10:00+00:00", + inline: { path: "b.ts", to: 4 }, + }, + // A plain remark, which belongs in the conversation rather than a thread. + { id: 4, content: { raw: "Nice" }, created_on: "2026-08-31T10:15:00+00:00" }, + ], + "octocat", + ); + + assert.deepStrictEqual( + threads.map((thread) => [ + thread.id, + thread.path, + thread.side, + thread.line, + thread.isResolved, + thread.isOutdated, + thread.comments.map((comment) => comment.id), + ]), + [ + ["1", "a.ts", "left", 12, true, true, ["1", "2"]], + ["3", "b.ts", "right", 4, false, false, ["3"]], + ], + ); + assert.deepStrictEqual( + threads[0]?.comments.map((comment) => comment.viewerIsAuthor), + [false, true], + ); + }); +}); + +describe("decodeBitbucketStatusesJson", () => { + it("reads the build states and keeps the later run of a repeated key", () => { + const page = success( + decodeBitbucketStatusesJson( + JSON.stringify({ + values: [ + { key: "build", name: "Build", state: "FAILED" }, + { key: "build", name: "Build", state: "SUCCESSFUL" }, + { key: "lint", name: "Lint", state: "INPROGRESS" }, + { key: "docs", name: "Docs", state: "SOMETHING_NEW" }, + ], + }), + ), + ); + + assert.deepStrictEqual( + page.items.map((check) => [check.name, check.status]), + [ + ["Build", "success"], + ["Lint", "pending"], + ["Docs", "skipped"], + ], + ); + }); +}); + +describe("decodeBitbucketDiffStatJson", () => { + it("totals the lines and counts the files", () => { + assert.deepStrictEqual( + success( + decodeBitbucketDiffStatJson( + JSON.stringify({ + values: [{ lines_added: 4, lines_removed: 1 }, { lines_added: 2 }], + }), + ), + ), + { additions: 6, deletions: 1, changedFiles: 2, next: null }, + ); + }); +}); + +describe("decodeBitbucketConflictsJson", () => { + it("reads an empty page as the only statement Bitbucket makes that a merge is clean", () => { + assert.equal( + success(decodeBitbucketConflictsJson(JSON.stringify({ values: [] }))), + "mergeable", + ); + assert.equal( + success(decodeBitbucketConflictsJson(JSON.stringify({ values: [{ path: "a.ts" }] }))), + "conflicting", + ); + }); +}); + +describe("decodeBitbucketRepositoryPermissionJson", () => { + it("grants a permission Bitbucket named none of, and refuses a read-only one", () => { + assert.equal(success(decodeBitbucketRepositoryPermissionJson(JSON.stringify({}))), true); + assert.equal( + success( + decodeBitbucketRepositoryPermissionJson( + JSON.stringify({ values: [{ permission: "read" }] }), + ), + ), + false, + ); + assert.equal( + success( + decodeBitbucketRepositoryPermissionJson( + JSON.stringify({ values: [{ permission: "write" }] }), + ), + ), + true, + ); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketPullRequest.ts b/apps/server/src/pullRequest/bitbucketPullRequest.ts new file mode 100644 index 000000000..a1b4c1c77 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequest.ts @@ -0,0 +1,799 @@ +import type * 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 { + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestCheckStatus, + type PullRequestComment, + type PullRequestCommit, + type PullRequestMergeability, + type PullRequestMergeMethod, + type PullRequestReviewCommentDraft, + type PullRequestReviewerCandidate, + type PullRequestReviewPosition, + type PullRequestReviewState, + type PullRequestReviewThread, + type PullRequestState, +} from "@threadlines/contracts"; +import { decodeJsonResult } from "@threadlines/shared/schemaJson"; + +type DecodeFailure = Cause.Cause; + +/** + * 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 BitbucketUserSchema = 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` stands in for it. */ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * Required, and required to be non-empty: the wire contract will not carry a + * pull request without a branch or a link, so a row missing one is skipped + * rather than breaking the response it travels in. + */ +const BitbucketBranchSchema = Schema.Struct({ + branch: Schema.Struct({ name: TrimmedNonEmptyString }), +}); + +const BitbucketPullRequestSchema = Schema.Struct({ + id: Schema.Int, + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.NullOr(Schema.Boolean)), + author: Schema.optional(Schema.NullOr(BitbucketUserSchema)), + source: BitbucketBranchSchema, + destination: BitbucketBranchSchema, + created_on: TrimmedNonEmptyString, + updated_on: TrimmedNonEmptyString, + reviewers: Schema.optional(Schema.NullOr(Schema.Array(BitbucketUserSchema))), + participants: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + user: Schema.optional(Schema.NullOr(BitbucketUserSchema)), + approved: Schema.optional(Schema.NullOr(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 BitbucketPageSchema = Schema.Struct({ + values: Schema.Array(Schema.Unknown), + /** Present only while a further page exists. */ + next: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const BitbucketCommentSchema = Schema.Struct({ + id: Schema.Int, + content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), + user: Schema.optional(Schema.NullOr(BitbucketUserSchema)), + created_on: TrimmedNonEmptyString, + deleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** A comment its author has not posted yet. */ + pending: Schema.optional(Schema.NullOr(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 somebody has marked the thread resolved. */ + resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), + links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + html: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +const BitbucketCommitSchema = 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(BitbucketUserSchema)), + }), + ), + ), +}); + +const BitbucketStatusSchema = 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 BitbucketDiffStatSchema = 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. */ +const BitbucketMemberSchema = Schema.Struct({ + user: Schema.optional(Schema.NullOr(BitbucketUserSchema)), +}); + +const BitbucketViewerSchema = Schema.Struct({ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * `/user/permissions/repositories` narrowed to one repository, which is the + * only place Bitbucket states what the credentials may do with it. + */ +const BitbucketRepositoryPermissionsSchema = Schema.Struct({ + values: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + ), +}); + +const BitbucketRepositorySchema = Schema.Struct({ + mainbranch: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +/** One decoded pull request, before the service attaches its project. */ +export interface BitbucketPullRequestRow { + 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 createdAt: string; + readonly updatedAt: string; + readonly body: string; + readonly reviewRequestedLogins: ReadonlyArray; + /** The reviewers as Bitbucket addresses them, which is what a write takes. */ + readonly reviewers: ReadonlyArray<{ readonly id: string; readonly login: string }>; + /** Approvals and change requests, which Bitbucket keeps on its participants. */ + readonly reviews: ReadonlyArray; +} + +export interface BitbucketPage { + readonly items: ReadonlyArray; + /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ + readonly next: string | null; +} + +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 rows + * 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 its display name is the only handle it has. */ +function toActor( + raw: Schema.Schema.Type | null | undefined, +): PullRequestActor | null { + const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); + // Bitbucket names no bot flag on the accounts it hands back. + return login === null ? null : { login, isBot: false }; +} + +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": + case "STOPPED": + return "failure"; + case "INPROGRESS": + return "pending"; + default: + return "skipped"; + } +} + +function toReviewState(input: { + readonly state?: string | null | undefined; + readonly approved?: boolean | null | undefined; +}): PullRequestReviewState | null { + switch (input.state?.trim().toLowerCase()) { + case "approved": + return "approved"; + case "changes_requested": + return "changes-requested"; + default: + return input.approved === true ? "approved" : null; + } +} + +/** + * A participant who has voted is the closest Bitbucket has to a review, so it + * reads as one in the conversation. Participants who were only 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 = toReviewState(participant); + if (author === null || votedAt === null || reviewState === null) { + return []; + } + return [ + { + id: `${raw.id}:${author.login}`, + kind: "review", + author, + // Bitbucket's verdict is a vote rather than a written review. + body: "", + createdAt: toIsoUtc(votedAt), + url: null, + reviewState, + reactions: [], + viewerIsAuthor: false, + }, + ]; + }); +} + +function toRow( + raw: Schema.Schema.Type, +): BitbucketPullRequestRow { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + const id = trimmed(reviewer.uuid); + return actor === null || id === null ? [] : [{ id, login: actor.login }]; + }); + 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 === true, + createdAt: toIsoUtc(raw.created_on), + updatedAt: toIsoUtc(raw.updated_on), + body: raw.description ?? "", + reviewRequestedLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + reviews: toReviews(raw), + }; +} + +const decodePage = decodeJsonResult(BitbucketPageSchema); +const decodePullRequest = decodeJsonResult(BitbucketPullRequestSchema); +const decodePullRequestEntry = Schema.decodeUnknownExit(BitbucketPullRequestSchema); +const decodeCommentEntry = Schema.decodeUnknownExit(BitbucketCommentSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(BitbucketCommitSchema); +const decodeStatusEntry = Schema.decodeUnknownExit(BitbucketStatusSchema); +const decodeDiffStatEntry = Schema.decodeUnknownExit(BitbucketDiffStatSchema); +const decodeMemberEntry = Schema.decodeUnknownExit(BitbucketMemberSchema); +const decodeViewer = decodeJsonResult(BitbucketViewerSchema); +const decodeRepositoryPermissions = decodeJsonResult(BitbucketRepositoryPermissionsSchema); +const decodeRepository = decodeJsonResult(BitbucketRepositorySchema); + +/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ +export function decodeBitbucketPullRequestPageJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const items: BitbucketPullRequestRow[] = []; + for (const entry of payload.success.values) { + const decoded = decodePullRequestEntry(entry); + if (Exit.isSuccess(decoded)) { + items.push(toRow(decoded.value)); + } + } + return Result.succeed({ items, next: trimmed(payload.success.next) }); +} + +export function decodeBitbucketPullRequestJson( + raw: string, +): Result.Result { + const payload = decodePullRequest(raw); + return Result.isSuccess(payload) + ? Result.succeed(toRow(payload.success)) + : Result.fail(payload.failure); +} + +export function decodeBitbucketViewerJson( + raw: string, +): Result.Result { + const payload = decodeViewer(raw); + return Result.isSuccess(payload) + ? Result.succeed(trimmed(payload.success.nickname) ?? trimmed(payload.success.display_name)) + : Result.fail(payload.failure); +} + +/** + * Whether the configured credentials may write, 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, leaving Bitbucket to refuse the merge and say why. + */ +export function decodeBitbucketRepositoryPermissionJson( + raw: string, +): Result.Result { + const payload = decodeRepositoryPermissions(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const permission = trimmed(payload.success.values?.[0]?.permission)?.toLowerCase() ?? null; + return Result.succeed(permission === null || permission === "admin" || permission === "write"); +} + +export function decodeBitbucketRepositoryJson( + raw: string, +): Result.Result { + const payload = decodeRepository(raw); + return Result.isSuccess(payload) + ? Result.succeed(trimmed(payload.success.mainbranch?.name)) + : Result.fail(payload.failure); +} + +/** One comment as Bitbucket sent it, kept so threads can be assembled from them. */ +export type BitbucketRawComment = Schema.Schema.Type; + +export interface BitbucketComments { + /** The conversation, which is every remark not pinned to a line. */ + readonly comments: ReadonlyArray; + /** The same comments unread, so the caller can assemble the line threads. */ + readonly entries: ReadonlyArray; + readonly next: string | null; +} + +/** + * Deleted comments and ones their author has not posted yet carry nothing to + * show. A comment pinned to a file opens a line conversation, which the Code + * tab reads from `buildBitbucketReviewThreads` rather than the Summary. + */ +export function decodeBitbucketCommentsJson( + raw: string, + viewer: string | null, +): Result.Result { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const viewerLogin = viewer?.toLowerCase() ?? null; + const comments: PullRequestComment[] = []; + const kept: BitbucketRawComment[] = []; + for (const entry of payload.success.values) { + const decoded = decodeCommentEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const comment = decoded.value; + const body = comment.content?.raw ?? ""; + if (comment.deleted === true || comment.pending === true || body.trim().length === 0) { + continue; + } + kept.push(comment); + if (trimmed(comment.inline?.path) !== null) { + continue; + } + const author = toActor(comment.user); + comments.push({ + id: String(comment.id), + kind: "issue-comment", + author, + body, + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + reviewState: null, + // Bitbucket exposes no reaction on a pull request or on a comment. + reactions: [], + viewerIsAuthor: viewerLogin !== null && author?.login.toLowerCase() === viewerLogin, + }); + } + return Result.succeed({ comments, entries: kept, next: trimmed(payload.success.next) }); +} + +/** + * 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 was never read has nowhere to go and is left out. + */ +export function buildBitbucketReviewThreads( + comments: ReadonlyArray, + viewer: string | null, +): ReadonlyArray { + const viewerLogin = viewer?.toLowerCase() ?? null; + const byId = new Map(comments.map((comment) => [comment.id, comment])); + const rootOf = (comment: BitbucketRawComment) => { + // Bounded by the number of comments read, so a parent cycle cannot spin. + let current = comment; + for (let step = 0; step < byId.size; step += 1) { + const parentId = current.parent?.id; + const parent = parentId === undefined ? undefined : byId.get(parentId); + 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 carrying only `from` was written against the removed side. + const side = inline?.to == null ? "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, + 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) => { + const author = toActor(comment.user); + return { + id: String(comment.id), + author, + body: comment.content?.raw ?? "", + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + reactions: [], + viewerIsAuthor: viewerLogin !== null && author?.login.toLowerCase() === viewerLogin, + }; + }); + return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; + }); +} + +export function decodeBitbucketCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of payload.success.values) { + const decoded = decodeCommitEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const commit = decoded.value; + const committedDate = trimmed(commit.date); + if (committedDate === null) { + continue; + } + commits.push({ + oid: commit.hash, + messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", + committedDate: toIsoUtc(committedDate), + authorLogin: toActor(commit.author?.user)?.login ?? trimmed(commit.author?.raw), + }); + } + // Bitbucket lists a pull request's commits newest first; the timeline reads + // oldest first. + return Result.succeed({ items: commits.toReversed(), next: trimmed(payload.success.next) }); +} + +/** + * Bitbucket re-uses a status key when a pipeline runs again, so the same check + * can appear twice on one page. Nothing decoded says which copy is newer, so the + * later one wins, which is the order Bitbucket writes an update in. + */ +export function decodeBitbucketStatusesJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const byName = new Map(); + for (const entry of payload.success.values) { + const decoded = decodeStatusEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const status = decoded.value; + const name = trimmed(status.name) ?? trimmed(status.key); + if (name === null) { + continue; + } + byName.set(trimmed(status.key) ?? name, { + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }); + } + return Result.succeed({ items: [...byName.values()], next: trimmed(payload.success.next) }); +} + +export interface BitbucketDiffStat { + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly next: string | null; +} + +/** One entry per changed file, each carrying that file's line counts. */ +export function decodeBitbucketDiffStatJson( + raw: string, +): Result.Result { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + let additions = 0; + let deletions = 0; + let changedFiles = 0; + for (const entry of payload.success.values) { + const decoded = decodeDiffStatEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + additions += decoded.value.lines_added ?? 0; + deletions += decoded.value.lines_removed ?? 0; + changedFiles += 1; + } + return Result.succeed({ + additions, + deletions, + changedFiles, + next: trimmed(payload.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 decodeBitbucketConflictsJson( + raw: string, +): Result.Result { + const payload = decodePage(raw); + return Result.isSuccess(payload) + ? Result.succeed(payload.success.values.length === 0 ? "mergeable" : "conflicting") + : Result.fail(payload.failure); +} + +/** + * 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, and + * a pull request can be sent to anyone in the workspace. + * + * Nobody is marked requested here: who has been asked lives on the pull request, + * and only the caller holds both. + */ +export function decodeBitbucketWorkspaceMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodePage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const items: PullRequestReviewerCandidate[] = []; + for (const entry of payload.success.values) { + const decoded = decodeMemberEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const uuid = trimmed(decoded.value.user?.uuid); + const actor = toActor(decoded.value.user); + if (uuid === null || actor === null) { + continue; + } + items.push({ + id: uuid, + kind: "user", + login: actor.login, + name: trimmed(decoded.value.user?.display_name), + requested: false, + }); + } + return Result.succeed({ items, next: trimmed(payload.success.next) }); +} + +/** Bitbucket's merge strategies, named differently from the contract's three. */ +export function bitbucketMergeStrategy(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"; + } +} + +function bitbucketPositionLine(position: PullRequestReviewPosition): { + readonly from?: number; + readonly to?: number; +} { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + +const BitbucketCommentBodySchema = Schema.Struct({ + content: Schema.Struct({ raw: Schema.String }), + parent: Schema.optionalKey(Schema.Struct({ id: Schema.Int })), + inline: Schema.optionalKey( + Schema.Struct({ + path: Schema.String, + from: Schema.optionalKey(Schema.Int), + to: Schema.optionalKey(Schema.Int), + }), + ), +}); +const encodeCommentBody = Schema.encodeSync(Schema.fromJsonString(BitbucketCommentBodySchema)); + +/** A plain remark, which is also how a review summary is posted. */ +export function buildBitbucketCommentJson(body: string): string { + return encodeCommentBody({ content: { raw: body } }); +} + +/** A reply, which Bitbucket keeps in the same collection under a parent. */ +export function buildBitbucketReplyJson(input: { + readonly parentId: string; + readonly body: string; +}): string { + return encodeCommentBody({ + content: { raw: input.body }, + parent: { id: Number(input.parentId) }, + }); +} + +export function buildBitbucketInlineCommentJson(comment: PullRequestReviewCommentDraft): string { + return encodeCommentBody({ + content: { raw: comment.body }, + inline: { path: comment.path, ...bitbucketPositionLine(comment.position) }, + }); +} + +const BitbucketPullRequestUpdateSchema = Schema.Struct({ + title: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), +}); +const encodePullRequestUpdate = Schema.encodeSync( + Schema.fromJsonString(BitbucketPullRequestUpdateSchema), +); + +/** + * Only the words this call rewrites travel in the body: Bitbucket's PUT is a + * partial update, so a field left out is left as it was. + */ +export function buildBitbucketPullRequestUpdateJson(input: { + readonly title?: string; + readonly body?: string; +}): string { + return encodePullRequestUpdate({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }); +} + +const BitbucketMergeSchema = Schema.Struct({ merge_strategy: Schema.String }); +const encodeMerge = Schema.encodeSync(Schema.fromJsonString(BitbucketMergeSchema)); + +export function buildBitbucketMergeJson(method: PullRequestMergeMethod | undefined): string { + return encodeMerge({ merge_strategy: bitbucketMergeStrategy(method) }); +} + +const BitbucketReviewersSchema = Schema.Struct({ + reviewers: Schema.Array(Schema.Struct({ uuid: Schema.String })), +}); +const encodeReviewers = Schema.encodeSync(Schema.fromJsonString(BitbucketReviewersSchema)); + +/** + * Bitbucket has no endpoint that adds or removes one reviewer: the pull + * request's `reviewers` is written whole, so the set already there is read first + * and the change applied to it. + */ +export function buildBitbucketReviewersJson(input: { + readonly current: ReadonlyArray; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; +}): string { + const uuids = new Set(input.current); + for (const reviewer of input.reviewers) { + if (input.requested) { + uuids.add(reviewer.id); + } else { + uuids.delete(reviewer.id); + } + } + return encodeReviewers({ reviewers: [...uuids].map((uuid) => ({ uuid })) }); +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestDetail.test.ts b/apps/server/src/pullRequest/gitHubPullRequestDetail.test.ts new file mode 100644 index 000000000..27015b5cf --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestDetail.test.ts @@ -0,0 +1,208 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { + decodeGitHubPullRequestActivityJson, + decodeGitHubPullRequestDetailJson, + decodeGitHubRepositoryJson, +} from "./gitHubPullRequestDetail.ts"; + +const baseDetail = { + number: 12, + title: "Add the pull request detail panel", + url: "https://github.com/octocat/example-app/pull/12", + author: { login: "octocat", is_bot: false }, + headRefName: "feature/pull-request-detail", + baseRefName: "main", + state: "OPEN", + mergedAt: null, + closedAt: null, + isDraft: false, + additions: 40, + deletions: 4, + changedFiles: 3, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-31T10:00:00Z", + body: "Reads one pull request.", + mergeable: "MERGEABLE", + reviewDecision: "", + reviewRequests: [], + reviews: [], + labels: [], + statusCheckRollup: [], +}; + +function decodeDetail(row: Record) { + const result = decodeGitHubPullRequestDetailJson(JSON.stringify({ ...baseDetail, ...row })); + if (!Result.isSuccess(result)) { + return assert.fail("expected the detail payload to decode"); + } + return result.success; +} + +function decodeRepository(payload: Record) { + const result = decodeGitHubRepositoryJson(JSON.stringify(payload)); + if (!Result.isSuccess(result)) { + return assert.fail("expected the repository payload to decode"); + } + return result.success; +} + +function decodeActivity(payload: Record) { + const result = decodeGitHubPullRequestActivityJson(JSON.stringify(payload)); + if (!Result.isSuccess(result)) { + return assert.fail("expected the activity payload to decode"); + } + return result.success; +} + +describe("decodeGitHubPullRequestDetailJson", () => { + it("lists a re-requested reviewer as pending, keeps a verdict over a later comment, and never the author", () => { + const detail = decodeDetail({ + author: { login: "octocat", is_bot: false }, + reviewRequests: [ + { __typename: "User", login: "hubot" }, + { __typename: "Team", name: "core", slug: "core" }, + ], + reviews: [ + { + author: { login: "hubot" }, + state: "CHANGES_REQUESTED", + submittedAt: "2026-08-30T11:00:00Z", + }, + { author: { login: "monalisa" }, state: "COMMENTED", submittedAt: "2026-08-30T12:00:00Z" }, + { author: { login: "monalisa" }, state: "APPROVED", submittedAt: "2026-08-30T13:00:00Z" }, + { author: { login: "octocat" }, state: "COMMENTED", submittedAt: "2026-08-30T14:00:00Z" }, + { author: { login: "monalisa" }, state: "COMMENTED", submittedAt: "2026-08-30T15:00:00Z" }, + ], + }); + + assert.deepStrictEqual(detail.reviewers, [ + { id: "hubot", kind: "user", login: "hubot", state: "pending" }, + { id: "monalisa", kind: "user", login: "monalisa", state: "approved" }, + ]); + }); + + it("keeps the last run of a repeated check and reports a skipped one as skipped", () => { + const detail = decodeDetail({ + statusCheckRollup: [ + { name: "build", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: "https://ci/1" }, + { name: "build", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: "https://ci/2" }, + { name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }, + { context: "legacy/status", state: "PENDING", targetUrl: "https://ci/legacy" }, + ], + }); + + assert.deepStrictEqual( + detail.checks.map((check) => [check.name, check.status, check.url]), + [ + ["build", "success", "https://ci/2"], + ["lint", "skipped", null], + ["legacy/status", "pending", "https://ci/legacy"], + ], + ); + }); + + it("reads mergeability and the settled timestamps", () => { + const detail = decodeDetail({ + state: "MERGED", + mergeable: "CONFLICTING", + mergedAt: "2026-08-31T12:00:00Z", + closedAt: "2026-08-31T12:00:00Z", + }); + + assert.equal(detail.state, "merged"); + assert.equal(detail.mergeability, "conflicting"); + assert.equal(detail.mergedAt, "2026-08-31T12:00:00Z"); + assert.equal(detail.closedAt, "2026-08-31T12:00:00Z"); + }); +}); + +describe("decodeGitHubPullRequestActivityJson", () => { + it("keeps a bodiless approval, drops a bodiless comment review, and orders by time", () => { + const activity = decodeActivity({ + comments: [ + { + id: "IC_2", + author: { login: "hubot" }, + body: "Second", + createdAt: "2026-08-30T12:00:00Z", + url: "https://github.com/octocat/example-app/pull/12#issuecomment-2", + }, + ], + reviews: [ + { + id: "PRR_1", + author: { login: "monalisa" }, + body: "", + state: "APPROVED", + submittedAt: "2026-08-30T13:00:00Z", + }, + { + id: "PRR_2", + author: { login: "monalisa" }, + body: "", + state: "COMMENTED", + submittedAt: "2026-08-30T11:00:00Z", + }, + { + id: "PRR_3", + author: { login: "hubot" }, + body: "Looks off", + state: "COMMENTED", + submittedAt: "2026-08-30T10:00:00Z", + }, + ], + commits: [ + { + oid: "abc123", + messageHeadline: "Add the panel", + committedDate: "2026-08-30T09:00:00Z", + authors: [{ login: "octocat" }], + }, + ], + }); + + assert.deepStrictEqual( + activity.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["PRR_3", "review", "commented"], + ["IC_2", "issue-comment", null], + ["PRR_1", "review", "approved"], + ], + ); + assert.deepStrictEqual(activity.commits, [ + { + oid: "abc123", + messageHeadline: "Add the panel", + committedDate: "2026-08-30T09:00:00Z", + authorLogin: "octocat", + }, + ]); + }); +}); + +describe("decodeGitHubRepositoryJson", () => { + it("reads push access, the default branch, and only the merge methods the repository allows", () => { + assert.deepStrictEqual( + decodeRepository({ + name: "example-app", + permissions: { admin: false, push: true, pull: true }, + allow_merge_commit: false, + allow_squash_merge: true, + allow_rebase_merge: true, + default_branch: "main", + }), + { canWrite: true, mergeMethods: ["squash", "rebase"], defaultBranch: "main" }, + ); + }); + + it("reads a repository with no permissions as read-only and an older host as allowing every method", () => { + assert.deepStrictEqual(decodeRepository({ name: "example-app" }), { + canWrite: false, + mergeMethods: ["merge", "squash", "rebase"], + defaultBranch: null, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestDetail.ts b/apps/server/src/pullRequest/gitHubPullRequestDetail.ts new file mode 100644 index 000000000..92e38d8e7 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestDetail.ts @@ -0,0 +1,425 @@ +import * as Cause from "effect/Cause"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCheck, + type PullRequestComment, + type PullRequestCommit, + type PullRequestMergeability, + type PullRequestMergeMethod, + type PullRequestReviewer, + type PullRequestReviewState, +} from "@threadlines/contracts"; +import { decodeJsonResult } from "@threadlines/shared/schemaJson"; + +import { + GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD, + GITHUB_PULL_REQUEST_LIST_FIELDS, + GitHubAuthorSchema, + GitHubPullRequestListRowSchema, + GitHubStatusCheckSchema, + nonEmptyText, + normalizeActor, + normalizeCheckStatus, + normalizeGitHubPullRequestListRow, + type GitHubPullRequestListRow, +} from "./gitHubPullRequestList.ts"; + +/** + * `gh pr view --json` fields for the detail header. The list fields carry the + * shared shape; the rest is what only the detail surface renders. + */ +export const GITHUB_PULL_REQUEST_DETAIL_FIELDS = [ + ...GITHUB_PULL_REQUEST_LIST_FIELDS, + GITHUB_PULL_REQUEST_LIST_CHECKS_FIELD, + "body", + "changedFiles", + "mergeable", + "closedAt", + "reviews", + "autoMergeRequest", + // Qualifies the head branch as `owner:branch`, which is the only name a + // branch on a fork has in the base repository. + "headRepositoryOwner", +] as const; + +/** `gh pr view --json` fields for the conversation below the header. */ +export const GITHUB_PULL_REQUEST_ACTIVITY_FIELDS = ["comments", "reviews", "commits"] as const; + +/** What the repository itself allows: the viewer's access and the merge buttons. */ +export interface GitHubRepositoryAccess { + readonly canWrite: boolean; + readonly mergeMethods: ReadonlyArray; + /** What a pull request has to target not to be stacked on other work. */ + readonly defaultBranch: string | null; +} + +/** The order the detail surface offers the allowed merge methods in. */ +const MERGE_METHOD_ORDER = [ + "merge", + "squash", + "rebase", +] as const satisfies ReadonlyArray; + +/** + * The REST repository record, picked down to what an action needs. `gh api` + * answers with the whole record and the picking happens here rather than in a + * `--jq` expression, so the mapping is the part under test. + */ +const GitHubRepositorySchema = Schema.Struct({ + permissions: Schema.optional( + Schema.NullOr(Schema.Struct({ push: Schema.optional(Schema.NullOr(Schema.Boolean)) })), + ), + allow_merge_commit: Schema.optional(Schema.NullOr(Schema.Boolean)), + allow_squash_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), + allow_rebase_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A `gh pr view` row: everything a list row carries, plus the detail fields. */ +export interface GitHubPullRequestDetailRow extends GitHubPullRequestListRow { + readonly body: string; + readonly changedFiles: number; + readonly mergeability: PullRequestMergeability; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + /** Null on a host too old to report an auto-merge instruction at all. */ + readonly autoMergeEnabled: boolean | null; + /** Qualifies the head branch when it lives on a fork. */ + readonly headRepositoryOwnerLogin: string | null; +} + +/** The conversation half of a `gh pr view` read, before GraphQL decorates it. */ +export interface GitHubPullRequestActivityRow { + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +/** Reviews carry `submittedAt` rather than the `createdAt` comments use. */ +const GitHubReviewSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubIssueCommentSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubCommitSchema = Schema.Struct({ + oid: TrimmedNonEmptyString, + messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), + committedDate: Schema.optional(Schema.NullOr(Schema.String)), + authors: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + ), +}); + +const GitHubPullRequestDetailRowSchema = Schema.Struct({ + ...GitHubPullRequestListRowSchema.fields, + body: Schema.optional(Schema.NullOr(Schema.String)), + changedFiles: Schema.optional(Schema.NullOr(NonNegativeInt)), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviews: Schema.optional(Schema.NullOr(Schema.Array(GitHubReviewSchema))), + /** An object while auto-merge is armed, null once it is not, absent on an older CLI. */ + autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Struct({}))), + headRepositoryOwner: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const GitHubPullRequestActivitySchema = Schema.Struct({ + comments: Schema.optional(Schema.NullOr(Schema.Array(GitHubIssueCommentSchema))), + reviews: Schema.optional(Schema.NullOr(Schema.Array(GitHubReviewSchema))), + commits: Schema.optional(Schema.NullOr(Schema.Array(GitHubCommitSchema))), +}); + +type GitHubReview = Schema.Schema.Type; + +/** Verdicts that stand on their own; `COMMENTED` needs a body to be worth showing. */ +const STANDALONE_REVIEW_STATES = new Set([ + "approved", + "changes-requested", + "dismissed", +]); + +function normalizeMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function normalizeReviewState(value: string | null | undefined): PullRequestReviewState | null { + switch (value?.trim().toUpperCase()) { + case "APPROVED": + return "approved"; + case "CHANGES_REQUESTED": + return "changes-requested"; + case "DISMISSED": + return "dismissed"; + case "COMMENTED": + return "commented"; + default: + return null; + } +} + +/** + * Requested users come first as `pending`: a fresh request outranks the verdict + * that triggered it. Otherwise a reviewer carries their latest verdict; a + * plain comment after a verdict leaves the verdict standing, which is how the + * host itself reports the reviewer. The pull request's own author is never + * listed as its reviewer. + */ +function normalizeReviewers(input: { + readonly authorLogin: string | null; + readonly reviewRequestedLogins: ReadonlyArray; + readonly reviews: ReadonlyArray; +}): ReadonlyArray { + const excluded = input.authorLogin?.toLowerCase() ?? null; + const requested = new Map(); + for (const login of input.reviewRequestedLogins) { + const key = login.toLowerCase(); + if (key !== excluded) { + // A GitHub user is addressed by their login; team requests never reach here. + requested.set(key, { id: login, kind: "user", login, state: "pending" }); + } + } + + const reviewed = new Map(); + for (const review of input.reviews) { + const login = nonEmptyText(review.author?.login); + const state = normalizeReviewState(review.state); + if (login === null || state === null) { + continue; + } + const key = login.toLowerCase(); + if (key === excluded || requested.has(key)) { + continue; + } + const previous = reviewed.get(key); + if (state === "commented" && previous !== undefined && previous.state !== "commented") { + continue; + } + reviewed.set(key, { id: login, kind: "user", login, state }); + } + + return [...requested.values(), ...reviewed.values()]; +} + +/** One row per check. A repeated name is a re-run, so the last one wins. */ +function normalizeChecks( + checks: ReadonlyArray> | null | undefined, +): ReadonlyArray { + const byName = new Map(); + for (const check of checks ?? []) { + const name = nonEmptyText(check.name) ?? nonEmptyText(check.context); + if (name === null) { + continue; + } + byName.set(name, { + name, + status: normalizeCheckStatus(check), + description: nonEmptyText(check.description), + url: nonEmptyText(check.detailsUrl) ?? nonEmptyText(check.targetUrl), + }); + } + return [...byName.values()]; +} + +function toComment(input: { + readonly kind: PullRequestComment["kind"]; + readonly id: string | null; + readonly author: Schema.Schema.Type | null | undefined; + readonly body: string | null; + readonly createdAt: string; + readonly url: string | null; + readonly reviewState: PullRequestReviewState | null; +}): PullRequestComment { + return { + // `gh` always sends node ids, but a comment is still worth rendering + // without one; the url or the timestamp keeps the row keyed. + id: input.id ?? input.url ?? `${input.kind}-${input.createdAt}`, + kind: input.kind, + author: normalizeActor(input.author), + body: input.body ?? "", + createdAt: input.createdAt, + url: input.url, + reviewState: input.reviewState, + // `gh pr view --json` reports neither; the activity read's own GraphQL + // document fills both in by node id. + reactions: [], + viewerIsAuthor: false, + }; +} + +function normalizeComments( + raw: Schema.Schema.Type, +): ReadonlyArray { + const comments: PullRequestComment[] = []; + + for (const comment of raw.comments ?? []) { + const createdAt = nonEmptyText(comment.createdAt); + if (createdAt === null) { + continue; + } + comments.push( + toComment({ + kind: "issue-comment", + id: nonEmptyText(comment.id), + author: comment.author, + body: comment.body ?? "", + createdAt, + url: nonEmptyText(comment.url), + reviewState: null, + }), + ); + } + + for (const review of raw.reviews ?? []) { + const createdAt = nonEmptyText(review.submittedAt) ?? nonEmptyText(review.createdAt); + if (createdAt === null) { + continue; + } + const body = review.body ?? ""; + const reviewState = normalizeReviewState(review.state); + // A bodiless `COMMENTED` review is GitHub's container for line comments, + // which the summary does not render; a bodiless verdict still counts. + const carriesVerdict = reviewState !== null && STANDALONE_REVIEW_STATES.has(reviewState); + if (body.trim().length === 0 && !carriesVerdict) { + continue; + } + comments.push( + toComment({ + kind: "review", + id: nonEmptyText(review.id), + author: review.author, + body, + createdAt, + url: nonEmptyText(review.url), + reviewState, + }), + ); + } + + // ISO timestamps sort lexicographically; equal ones keep the host's order. + return comments.sort((left, right) => + left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : 0, + ); +} + +function normalizeCommits( + raw: Schema.Schema.Type, +): ReadonlyArray { + return (raw.commits ?? []).flatMap((commit) => { + const committedDate = nonEmptyText(commit.committedDate); + if (committedDate === null) { + return []; + } + return [ + { + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate, + authorLogin: nonEmptyText(commit.authors?.[0]?.login), + } satisfies PullRequestCommit, + ]; + }); +} + +const decodeDetailPayload = decodeJsonResult(GitHubPullRequestDetailRowSchema); +const decodeActivityPayload = decodeJsonResult(GitHubPullRequestActivitySchema); +const decodeRepositoryPayload = decodeJsonResult(GitHubRepositorySchema); + +/** + * Decodes `gh api repos//` into the viewer's access and the merge + * methods the repository allows. No `permissions` object means no push access. + * A host that reports none of the three switches is not forbidding anything, so + * all three are offered and the host still refuses what it does not allow. + */ +export function decodeGitHubRepositoryJson( + raw: string, +): Result.Result> { + const payload = decodeRepositoryPayload(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + + const row = payload.success; + const switches = [row.allow_merge_commit, row.allow_squash_merge, row.allow_rebase_merge]; + const reported = switches.some((value) => value !== undefined && value !== null); + + return Result.succeed({ + canWrite: row.permissions?.push === true, + mergeMethods: MERGE_METHOD_ORDER.filter( + (_method, index) => !reported || switches[index] === true, + ), + defaultBranch: nonEmptyText(row.default_branch), + }); +} + +/** Decodes `gh pr view --json ` into the header the panel renders. */ +export function decodeGitHubPullRequestDetailJson( + raw: string, +): Result.Result> { + const payload = decodeDetailPayload(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + + const row = payload.success; + const base = normalizeGitHubPullRequestListRow(row); + return Result.succeed({ + ...base, + body: row.body ?? "", + changedFiles: row.changedFiles ?? 0, + mergeability: normalizeMergeability(row.mergeable), + mergedAt: nonEmptyText(row.mergedAt), + closedAt: nonEmptyText(row.closedAt), + reviewers: normalizeReviewers({ + authorLogin: base.author?.login ?? null, + reviewRequestedLogins: base.reviewRequestedLogins, + reviews: row.reviews ?? [], + }), + checks: normalizeChecks(row.statusCheckRollup), + // A CLI too old for the field leaves it absent, which is "the host did not + // say" rather than "auto-merge is off". + autoMergeEnabled: row.autoMergeRequest === undefined ? null : row.autoMergeRequest !== null, + headRepositoryOwnerLogin: nonEmptyText(row.headRepositoryOwner?.login), + }); +} + +/** Decodes `gh pr view --json comments,reviews,commits` into one ordered conversation. */ +export function decodeGitHubPullRequestActivityJson( + raw: string, +): Result.Result> { + const payload = decodeActivityPayload(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + + return Result.succeed({ + comments: normalizeComments(payload.success), + commits: normalizeCommits(payload.success), + }); +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts b/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts new file mode 100644 index 000000000..07b8ac537 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestGraphql.test.ts @@ -0,0 +1,341 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { + decodeGitHubAuthoredPullRequestsJson, + decodeGitHubBaseComparisonJson, + decodeGitHubPullRequestConversationJson, + decodeGitHubReviewerCandidatesJson, + decodeGitHubSubjectScopeJson, +} from "./gitHubPullRequestGraphql.ts"; + +function decoded(result: Result.Result, subject: string): A { + if (!Result.isSuccess(result)) { + return assert.fail(`expected the ${subject} payload to decode`); + } + return result.success; +} + +const reactionGroup = (content: string, count: number, viewerHasReacted = false) => ({ + content, + viewerHasReacted, + users: { totalCount: count }, +}); + +describe("decodeGitHubPullRequestConversationJson", () => { + it("pins a live thread to its line, lists an outdated one without one, and counts reactions", () => { + const conversation = decoded( + decodeGitHubPullRequestConversationJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reactionGroups: [reactionGroup("ROCKET", 2, true), reactionGroup("HEART", 0)], + reviewThreads: { + nodes: [ + { + id: "PRRT_live", + isResolved: false, + isOutdated: false, + path: "src/app.ts", + line: 42, + diffSide: "RIGHT", + comments: { + nodes: [ + { + id: "PRRC_1", + author: { login: "hubot" }, + body: "This reads twice.", + createdAt: "2026-08-30T10:00:00Z", + url: "https://github.com/octocat/example-app/pull/12#discussion_r1", + viewerDidAuthor: false, + reactionGroups: [reactionGroup("THUMBS_UP", 1, true)], + }, + ], + }, + }, + { + id: "PRRT_outdated", + isResolved: true, + isOutdated: true, + path: "src/old.ts", + line: null, + diffSide: "LEFT", + comments: { + nodes: [ + { + id: "PRRC_2", + author: null, + body: "Gone now.", + createdAt: "2026-08-29T10:00:00Z", + url: null, + viewerDidAuthor: true, + reactionGroups: [], + }, + ], + }, + }, + // A thread with nothing said in it is not a card to render. + { + id: "PRRT_empty", + path: "src/empty.ts", + line: 1, + comments: { nodes: [] }, + }, + ], + }, + comments: { + nodes: [ + { + id: "IC_1", + viewerDidAuthor: true, + reactionGroups: [reactionGroup("EYES", 3)], + }, + ], + }, + reviews: { nodes: [{ id: "PRR_1", viewerDidAuthor: false, reactionGroups: [] }] }, + }, + }, + }, + }), + ), + "conversation", + ); + + assert.deepStrictEqual(conversation.reactions, [ + { content: "rocket", count: 2, viewerReacted: true }, + ]); + assert.deepStrictEqual( + conversation.reviewThreads.map((thread) => [ + thread.id, + thread.line, + thread.side, + thread.isOutdated, + ]), + [ + ["PRRT_live", 42, "right", false], + ["PRRT_outdated", null, "left", true], + ], + ); + assert.deepStrictEqual(conversation.reviewThreads[0]?.comments[0]?.reactions, [ + { content: "thumbs-up", count: 1, viewerReacted: true }, + ]); + assert.equal(conversation.reviewThreads[1]?.comments[0]?.viewerIsAuthor, true); + assert.deepStrictEqual(conversation.annotationsByCommentId.get("IC_1"), { + reactions: [{ content: "eyes", count: 3, viewerReacted: false }], + viewerIsAuthor: true, + }); + assert.deepStrictEqual(conversation.annotationsByCommentId.get("PRR_1"), { + reactions: [], + viewerIsAuthor: false, + }); + }); +}); + +describe("decodeGitHubBaseComparisonJson", () => { + it("counts the commits the base is ahead by", () => { + assert.equal( + decoded( + decodeGitHubBaseComparisonJson( + JSON.stringify({ + data: { repository: { pullRequest: { baseRef: { compare: { behindBy: 7 } } } } }, + }), + ), + "base comparison", + ), + 7, + ); + }); + + it("reads a comparison the host would not make as unknown", () => { + assert.equal( + decoded( + decodeGitHubBaseComparisonJson( + JSON.stringify({ data: { repository: { pullRequest: { baseRef: null } } } }), + ), + "base comparison", + ), + null, + ); + }); +}); + +describe("decodeGitHubReviewerCandidatesJson", () => { + it("marks whoever has been asked, keeps teams, and drops the author", () => { + const list = decoded( + decodeGitHubReviewerCandidatesJson( + JSON.stringify({ + data: { + repository: { + assignableUsers: { + nodes: [ + { login: "octocat", name: "Mona" }, + { login: "hubot", name: "Hubot" }, + { login: "monalisa", name: null }, + ], + }, + pullRequest: { + author: { login: "octocat" }, + reviewRequests: { + nodes: [ + { requestedReviewer: { login: "hubot", name: "Hubot" } }, + { requestedReviewer: { slug: "core", name: "Core team" } }, + ], + }, + }, + }, + }, + }), + ), + "reviewer candidates", + ); + + assert.deepStrictEqual( + list.candidates.map((candidate) => [ + candidate.id, + candidate.kind, + candidate.name, + candidate.requested, + ]), + [ + ["hubot", "user", "Hubot", true], + ["core", "team", "Core team", true], + ["monalisa", "user", null, false], + ], + ); + }); +}); + +describe("decodeGitHubSubjectScopeJson", () => { + it("accepts a subject on this pull request and refuses one from elsewhere", () => { + const scope = (nodePullRequestId: string) => + decoded( + decodeGitHubSubjectScopeJson( + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_here" } }, + node: { id: "IC_1", pullRequest: { id: nodePullRequestId } }, + }, + }), + ), + "subject scope", + ); + + assert.equal(scope("PR_here"), true); + assert.equal(scope("PR_elsewhere"), false); + }); + + it("refuses a subject the host could not find at all", () => { + assert.equal( + decoded( + decodeGitHubSubjectScopeJson( + JSON.stringify({ + data: { repository: { pullRequest: { id: "PR_here" } }, node: null }, + }), + ), + "subject scope", + ), + false, + ); + }); +}); + +describe("decodeGitHubAuthoredPullRequestsJson", () => { + const node = (overrides: Record) => ({ + number: 12, + title: "Teach the runner to wait", + url: "https://github.com/openai/codex/pull/12", + isDraft: false, + state: "OPEN", + mergedAt: null, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-31T10:00:00Z", + headRefName: "fix/waiting", + baseRefName: "main", + additions: 8, + deletions: 2, + reviewDecision: "CHANGES_REQUESTED", + author: { login: "octocat" }, + repository: { nameWithOwner: "openai/codex" }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + ...overrides, + }); + + it("reads a search node as a list row that names its own repository", () => { + const rows = decoded( + decodeGitHubAuthoredPullRequestsJson( + JSON.stringify({ data: { search: { nodes: [node({})] } } }), + ), + "authored search", + ); + + assert.deepStrictEqual(rows, [ + { + number: 12, + title: "Teach the runner to wait", + url: "https://github.com/openai/codex/pull/12", + author: { login: "octocat", isBot: false }, + headBranch: "fix/waiting", + baseBranch: "main", + state: "open", + isDraft: false, + additions: 8, + deletions: 2, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-31T10:00:00Z", + reviewRequestedLogins: ["hubot"], + reviewDecision: "changes-requested", + checksState: "failure", + labels: [{ name: "bug", color: "d73a4a" }], + repository: "openai/codex", + }, + ]); + }); + + it("collapses the rollup the search reports into the word a row shows", () => { + const checksState = (state: string | null) => + decoded( + decodeGitHubAuthoredPullRequestsJson( + JSON.stringify({ + data: { + search: { + nodes: [ + node({ + commits: { + nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], + }, + }), + ], + }, + }, + }), + ), + "authored search", + )[0]?.checksState; + + assert.equal(checksState("SUCCESS"), "success"); + assert.equal(checksState("ERROR"), "failure"); + assert.equal(checksState("EXPECTED"), "pending"); + // A pull request with no checks at all says nothing about them. + assert.equal(checksState(null), undefined); + }); + + it("drops a search hit that is not a pull request", () => { + const rows = decoded( + decodeGitHubAuthoredPullRequestsJson( + // `type: ISSUE` answers with issues too, which match no field the + // fragment asks for and arrive as empty nodes. + JSON.stringify({ data: { search: { nodes: [{}, node({})] } } }), + ), + "authored search", + ); + + assert.deepStrictEqual( + rows.map((row) => row.number), + [12], + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestGraphql.ts b/apps/server/src/pullRequest/gitHubPullRequestGraphql.ts new file mode 100644 index 000000000..e4f581547 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestGraphql.ts @@ -0,0 +1,897 @@ +import type * as Cause from "effect/Cause"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import type { + PullRequestListState, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewerCandidate, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestReviewPosition, + PullRequestReviewThread, + PullRequestReviewVerdict, +} from "@threadlines/contracts"; +import { decodeJsonResult } from "@threadlines/shared/schemaJson"; + +import { + decodeGitHubPullRequestListRow, + nonEmptyText, + normalizeActor, + GitHubAuthorSchema, + type GitHubPullRequestListRow, +} from "./gitHubPullRequestList.ts"; + +type DecodeFailure = Cause.Cause; + +/** GitHub's own ceiling on a connection page, which is what every read here asks for. */ +const GRAPHQL_PAGE_SIZE = 100; + +/** + * The reaction counts on one node. `users { totalCount }` is the whole tally; + * `viewerHasReacted` is the reader's own place in it. + */ +const REACTION_GROUPS_FIELDS = "reactionGroups { content viewerHasReacted users { totalCount } }"; + +/** + * Everything the conversation needs that `gh pr view --json` cannot report: the + * review threads on diff lines, and the reactions and authorship marks on the + * pull request and on every remark in it. One document, one request per + * activity read. + */ +export const PULL_REQUEST_CONVERSATION_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ${REACTION_GROUPS_FIELDS} + reviewThreads(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + id + isResolved + isOutdated + path + line + diffSide + comments(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + id + author { login } + body + createdAt + url + viewerDidAuthor + ${REACTION_GROUPS_FIELDS} + } + } + } + } + comments(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { id viewerDidAuthor ${REACTION_GROUPS_FIELDS} } + } + reviews(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { id viewerDidAuthor ${REACTION_GROUPS_FIELDS} } + } + } + } +}`; + +/** + * How far the head branch trails its base. + * + * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the + * repository requires branches to be current before merging. The comparison + * counts the commits instead, which is the number GitHub's own banner shows. + * + * `headRef` is qualified `owner:branch` because a branch on a fork has no name + * of its own in the base repository. + */ +export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + baseRef { compare(headRef: $headRef) { behindBy } } + } + } +}`; + +/** Labels and outstanding review requests are short lists; this is room to spare. */ +const AUTHORED_CONNECTION_PAGE_SIZE = 20; + +/** + * The viewer's own pull requests across the whole host, wherever they are. One + * search stands in for a listing per repository, which is the only way to reach + * a contribution to a repository nobody here has checked out. + * + * `first` is a variable so the listing asks for as many rows as it would from a + * repository. The fields are the ones a list row is built from, plus the + * repository each one is on and the check rollup of its last commit, since a + * search cannot be asked for `statusCheckRollup` the way `gh pr list` is. + */ +export const AUTHORED_PULL_REQUESTS_GRAPHQL_QUERY = `query($q: String!, $first: Int!) { + search(query: $q, type: ISSUE, first: $first) { + nodes { + ... on PullRequest { + number + title + url + isDraft + state + mergedAt + createdAt + updatedAt + headRefName + baseRefName + additions + deletions + reviewDecision + author { login } + repository { nameWithOwner } + labels(first: ${AUTHORED_CONNECTION_PAGE_SIZE}) { nodes { name color } } + reviewRequests(first: ${AUTHORED_CONNECTION_PAGE_SIZE}) { + nodes { requestedReviewer { ... on User { login } } } + } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + } + } + } +}`; + +/** + * GitHub counts a merged pull request as closed as well, so the closed slice + * has to say it is the unmerged half of that. + */ +const AUTHORED_SEARCH_STATE: Readonly> = { + open: "is:open", + merged: "is:merged", + closed: "is:closed is:unmerged", +}; + +/** The search phrase {@link AUTHORED_PULL_REQUESTS_GRAPHQL_QUERY} runs. */ +export function gitHubAuthoredSearchQuery(input: { + readonly viewer: string; + readonly state: PullRequestListState; +}): string { + return `is:pr author:${input.viewer.trim()} ${AUTHORED_SEARCH_STATE[input.state]}`; +} + +/** The people this pull request may be sent to, with whoever is on it already marked. */ +export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { login name } + } + pullRequest(number: $number) { + author { login } + reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + requestedReviewer { + ... on User { login name } + ... on Team { slug name } + ... on Bot { login } + } + } + } + } + } +}`; + +/** + * The pull request's own node id, which is what a reaction on its description + * is addressed by. Read only when one is being written: the conversation + * carries an id for every remark in it, and the pull request is the one subject + * nothing in it names. + */ +export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } +}`; + +/** + * Where a client-given subject actually hangs. A subject id is trusted to be + * whatever node it names, and that node can belong to any pull request on the + * host, so the mutation would write wherever the id really points unless this + * confirms the two agree first. + */ +export const REACTION_SUBJECT_SCOPE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } + node(id: $subjectId) { + id + ... on IssueComment { pullRequest { id } } + ... on PullRequestReviewComment { pullRequest { id } } + ... on PullRequestReview { pullRequest { id } } + } +}`; + +export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +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 } } +}`; + +export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}`; + +/** + * The two comment mutations name their comment differently, but the variable is + * spelled the same in both, so a rewrite sends one set of variables whichever + * kind of remark it is. + */ +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { + pullRequestReviewComment { id } + } +}`; + +const GraphQlRequestSchema = Schema.Struct({ + query: Schema.String, + variables: Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Null]), + ), +}); + +const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema)); + +/** + * A GraphQL request as `gh api graphql --input -` takes it. Document and + * variables travel together on stdin, so a reader's own words never reach argv, + * where they would show up in process listings and in failure messages. + */ +export function encodeGraphQlRequestJson(input: { + readonly query: string; + readonly variables: Readonly>; +}): string { + return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); +} + +const REACTION_CONTENT_BY_GITHUB: Readonly> = { + THUMBS_UP: "thumbs-up", + THUMBS_DOWN: "thumbs-down", + LAUGH: "laugh", + HOORAY: "hooray", + CONFUSED: "confused", + HEART: "heart", + ROCKET: "rocket", + EYES: "eyes", +}; + +const GITHUB_REACTION_BY_CONTENT: Readonly> = { + "thumbs-up": "THUMBS_UP", + "thumbs-down": "THUMBS_DOWN", + laugh: "LAUGH", + hooray: "HOORAY", + confused: "CONFUSED", + heart: "HEART", + rocket: "ROCKET", + eyes: "EYES", +}; + +/** The enum value a reaction mutation names this reaction by. */ +export function gitHubReactionContent(content: PullRequestReactionContent): string { + return GITHUB_REACTION_BY_CONTENT[content]; +} + +const RawReactionGroupsSchema = Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + viewerHasReacted: Schema.optional(Schema.NullOr(Schema.Boolean)), + users: Schema.optional( + Schema.NullOr( + Schema.Struct({ totalCount: Schema.optional(Schema.NullOr(Schema.Number)) }), + ), + ), + }), + ), + ), +); + +type RawReactionGroups = typeof RawReactionGroupsSchema.Type; + +/** + * The groups GitHub answered with, as the contract carries them. A group nobody + * chose is dropped: GitHub answers with a group per content it knows, including + * every empty one. + */ +function toReactions(groups: RawReactionGroups): ReadonlyArray { + const reactions: PullRequestReaction[] = []; + for (const group of groups ?? []) { + const content = REACTION_CONTENT_BY_GITHUB[nonEmptyText(group.content)?.toUpperCase() ?? ""]; + const count = Math.trunc(group.users?.totalCount ?? 0); + if (content === undefined || count <= 0) { + continue; + } + reactions.push({ content, count, viewerReacted: group.viewerHasReacted === true }); + } + return reactions; +} + +const RawThreadCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + viewerDidAuthor: Schema.optional(Schema.NullOr(Schema.Boolean)), + reactionGroups: RawReactionGroupsSchema, +}); + +const RawAnnotatedNodeSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + viewerDidAuthor: Schema.optional(Schema.NullOr(Schema.Boolean)), + reactionGroups: RawReactionGroupsSchema, +}); + +const RawConversationSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + reactionGroups: RawReactionGroupsSchema, + reviewThreads: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + isResolved: Schema.optional(Schema.NullOr(Schema.Boolean)), + isOutdated: Schema.optional(Schema.NullOr(Schema.Boolean)), + path: Schema.optional(Schema.NullOr(Schema.String)), + line: Schema.optional(Schema.NullOr(Schema.Number)), + diffSide: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ nodes: Schema.Array(RawThreadCommentSchema) }), + ), + ), + }), + ), + ), + }), + ), + ), + comments: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawAnnotatedNodeSchema) })), + ), + reviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawAnnotatedNodeSchema) })), + ), + }), + ), + }), + ), + }), +}); + +/** What a GraphQL read adds to a remark `gh pr view --json` already reported. */ +export interface GitHubCommentAnnotation { + readonly reactions: ReadonlyArray; + readonly viewerIsAuthor: boolean; +} + +/** Everything one activity read learns from GraphQL, keyed the way it is used. */ +export interface GitHubPullRequestConversation { + readonly reviewThreads: ReadonlyArray; + /** The pull request description's own reactions. */ + readonly reactions: ReadonlyArray; + /** Keyed by the node id `gh pr view --json` reports for the same remark. */ + readonly annotationsByCommentId: ReadonlyMap; +} + +const decodeConversation = decodeJsonResult(RawConversationSchema); + +/** + * Decodes the one GraphQL read an activity makes: the line conversations, plus + * the reactions and authorship marks for the remarks the JSON read carries. + * + * A thread with no id, no path, or no comments is dropped rather than rendered + * as an empty card. `line` is null once the line the thread hung on has left + * the diff, which is exactly when GitHub reports the thread outdated. + */ +export function decodeGitHubPullRequestConversationJson( + raw: string, +): Result.Result { + const decoded = decodeConversation(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + + const pullRequest = decoded.success.data.repository?.pullRequest ?? null; + const reviewThreads = (pullRequest?.reviewThreads?.nodes ?? []).flatMap( + (thread): ReadonlyArray => { + const id = nonEmptyText(thread?.id); + const path = nonEmptyText(thread?.path); + const comments = thread?.comments?.nodes ?? []; + if (id === null || path === null || comments.length === 0) { + return []; + } + const line = thread?.line ?? null; + return [ + { + id, + path, + line: line !== null && line > 0 ? Math.trunc(line) : null, + side: thread?.diffSide?.trim().toUpperCase() === "LEFT" ? "left" : "right", + isResolved: thread?.isResolved === true, + isOutdated: thread?.isOutdated === true, + comments: comments.map((comment) => ({ + id: comment.id, + author: normalizeActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: nonEmptyText(comment.url), + reactions: toReactions(comment.reactionGroups), + viewerIsAuthor: comment.viewerDidAuthor === true, + })), + }, + ]; + }, + ); + + const annotationsByCommentId = new Map(); + for (const node of [ + ...(pullRequest?.comments?.nodes ?? []), + ...(pullRequest?.reviews?.nodes ?? []), + ]) { + const id = nonEmptyText(node.id); + if (id === null) { + continue; + } + annotationsByCommentId.set(id, { + reactions: toReactions(node.reactionGroups), + viewerIsAuthor: node.viewerDidAuthor === true, + }); + } + + return Result.succeed({ + reviewThreads, + reactions: toReactions(pullRequest?.reactionGroups), + annotationsByCommentId, + }); +} + +const RawBaseComparisonSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + /** Null where the head repository is gone, a comparison nobody can make. */ + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ + compare: Schema.optional( + Schema.NullOr( + Schema.Struct({ behindBy: Schema.optional(Schema.NullOr(Schema.Number)) }), + ), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema); + +/** How many commits the base has that the head does not; null when unanswerable. */ +export function decodeGitHubBaseComparisonJson( + raw: string, +): Result.Result { + const decoded = decodeBaseComparison(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const behindBy = decoded.success.data.repository?.pullRequest?.baseRef?.compare?.behindBy; + return Result.succeed( + typeof behindBy === "number" && behindBy >= 0 ? Math.trunc(behindBy) : null, + ); +} + +const RawAuthoredNodeSchema = Schema.Struct({ + number: Schema.optional(Schema.NullOr(Schema.Number)), + title: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + state: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.NullOr(Schema.String)), + headRefName: Schema.optional(Schema.NullOr(Schema.String)), + baseRefName: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.NullOr(Schema.Number)), + deletions: Schema.optional(Schema.NullOr(Schema.Number)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + repository: Schema.optional( + Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + color: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + requestedReviewer: Schema.optional( + Schema.NullOr( + Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + ), + }), + ), + ), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.optional( + Schema.NullOr( + Schema.Struct({ + statusCheckRollup: Schema.optional( + Schema.NullOr( + Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + ), + }), + ), + ), + }), + ), + ), +}); + +type RawAuthoredNode = typeof RawAuthoredNodeSchema.Type; + +const RawAuthoredSearchSchema = Schema.Struct({ + data: Schema.Struct({ + search: Schema.NullOr( + Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawAuthoredNodeSchema)) }), + ), + }), +}); + +const decodeAuthoredSearch = decodeJsonResult(RawAuthoredSearchSchema); + +/** One of the viewer's own pull requests, and the repository it is on. */ +export interface GitHubAuthoredPullRequestRow extends GitHubPullRequestListRow { + readonly repository: string; +} + +/** + * A search node in the shape `gh pr list --json` reports, so one decoder + * normalises both listings. The rollup arrives as the single verdict GitHub + * already summed rather than the per-check array `gh` hands over, so it travels + * as the one check it stands for; a label with no name and a team review + * request both survive the trip and are dropped where the list drops them. + */ +function toGitHubListRowShape(node: RawAuthoredNode): unknown { + const rollupState = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null; + return { + number: node.number, + title: node.title, + url: node.url, + author: node.author, + headRefName: node.headRefName, + baseRefName: node.baseRefName, + state: node.state, + mergedAt: node.mergedAt, + isDraft: node.isDraft, + additions: node.additions, + deletions: node.deletions, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + reviewDecision: node.reviewDecision, + reviewRequests: (node.reviewRequests?.nodes ?? []).map((request) => ({ + login: request?.requestedReviewer?.login ?? null, + })), + labels: (node.labels?.nodes ?? []).map((label) => ({ + name: label?.name ?? "", + color: label?.color ?? null, + })), + statusCheckRollup: rollupState === null ? null : [{ state: rollupState }], + }; +} + +/** + * Decodes the authored search. `type: ISSUE` answers with issues as well as + * pull requests, and a node that is neither in the shape a row needs, nor on a + * repository it can name, is dropped rather than failing the whole search. + */ +export function decodeGitHubAuthoredPullRequestsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeAuthoredSearch(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + + const rows: GitHubAuthoredPullRequestRow[] = []; + for (const node of decoded.success.data.search?.nodes ?? []) { + const repository = nonEmptyText(node?.repository?.nameWithOwner); + if (node === null || repository === null) { + continue; + } + const row = decodeGitHubPullRequestListRow(toGitHubListRowShape(node)); + if (row !== null) { + rows.push({ ...row, repository }); + } + } + return Result.succeed(rows); +} + +const RawPullRequestNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })), + }), + ), + }), +}); + +const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); + +/** The pull request's node id, or null when the host would not name one. */ +export function decodeGitHubPullRequestNodeIdJson( + raw: string, +): Result.Result { + const decoded = decodePullRequestNodeId(raw); + return Result.isSuccess(decoded) + ? Result.succeed(nonEmptyText(decoded.success.data.repository?.pullRequest?.id)) + : Result.fail(decoded.failure); +} + +const RawReactionSubjectScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + pullRequest: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.String }))), + }), + ), + }), +}); + +const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); + +/** + * True when the subject named is this pull request, or hangs off it. False for + * anything else, including a subject or a pull request the host could not find. + */ +export function decodeGitHubSubjectScopeJson(raw: string): Result.Result { + const decoded = decodeReactionSubjectScope(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const expected = decoded.success.data.repository?.pullRequest?.id ?? null; + const node = decoded.success.data.node; + const actual = node === null ? null : (node.pullRequest?.id ?? node.id); + return Result.succeed(expected !== null && actual !== null && expected === actual); +} + +const RawRequestedReviewerSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewerCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + assignableUsers: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array(Schema.NullOr(RawRequestedReviewerSchema)), + }), + ), + ), + pullRequest: Schema.NullOr( + Schema.Struct({ + author: Schema.optional(Schema.NullOr(GitHubAuthorSchema)), + 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. Whoever has already been asked + * leads the list even where GitHub does not count them assignable, because a + * request that cannot be seen cannot be taken back. The author is dropped + * rather than offered as a row the host would refuse. + */ +export function decodeGitHubReviewerCandidatesJson( + 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 ?? null; + const author = nonEmptyText(pullRequest?.author?.login)?.toLowerCase() ?? null; + const candidates = new Map(); + + for (const node of pullRequest?.reviewRequests?.nodes ?? []) { + const slug = nonEmptyText(node.requestedReviewer?.slug); + const id = slug ?? nonEmptyText(node.requestedReviewer?.login); + if (id === null) { + continue; + } + const kind: PullRequestReviewerKind = slug === null ? "user" : "team"; + candidates.set(`${kind}:${id.toLowerCase()}`, { + id, + kind, + login: id, + name: nonEmptyText(node.requestedReviewer?.name), + requested: true, + }); + } + + for (const node of repository?.assignableUsers?.nodes ?? []) { + const login = nonEmptyText(node?.login); + if (login === null || login.toLowerCase() === author) { + continue; + } + const key = `user:${login.toLowerCase()}`; + if (candidates.has(key)) { + continue; + } + candidates.set(key, { + id: login, + kind: "user", + login, + name: nonEmptyText(node?.name), + requested: false, + }); + } + + return Result.succeed({ candidates: [...candidates.values()] }); +} + +/** 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.Number, + side: Schema.Literals(["LEFT", "RIGHT"]), + body: Schema.String, + }), + ), +}); + +const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); + +const REVIEW_EVENTS: Readonly< + Record +> = { + comment: "COMMENT", + approve: "APPROVE", + "request-changes": "REQUEST_CHANGES", +}; + +/** + * A line the change added exists only on the right, a line it deleted only on + * the left, and a context line on whichever side the reader picked it from. + */ +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + +/** The whole review as one request body, which is how GitHub keeps it unsent until it is. */ +export function buildGitHubReviewSubmissionJson(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, + ...gitHubReviewPosition(comment.position), + body: comment.body, + })), + }); +} + +/** + * 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 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 buildGitHubReviewerRequestJson( + 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] : [], + ), + }); +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestList.ts b/apps/server/src/pullRequest/gitHubPullRequestList.ts index b86e5b059..deffa5438 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestList.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestList.ts @@ -6,6 +6,7 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString, + type PullRequestCheckStatus, type PullRequestChecksState, type PullRequestReviewDecision, type PullRequestState, @@ -60,7 +61,7 @@ export interface GitHubPullRequestListRow { readonly labels: ReadonlyArray<{ readonly name: string; readonly color: string | null }>; } -const GitHubAuthorSchema = Schema.Struct({ +export const GitHubAuthorSchema = Schema.Struct({ login: Schema.String, is_bot: Schema.optional(Schema.NullOr(Schema.Boolean)), isBot: Schema.optional(Schema.NullOr(Schema.Boolean)), @@ -76,13 +77,23 @@ const GitHubReviewRequestSchema = Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), }); -const GitHubStatusCheckSchema = Schema.Struct({ +/** + * One `statusCheckRollup` entry. `gh` reports check runs (`name`, `status`, + * `conclusion`, `detailsUrl`) and legacy commit statuses (`context`, `state`, + * `targetUrl`) side by side in the same array. + */ +export const GitHubStatusCheckSchema = Schema.Struct({ + 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 GitHubPullRequestListRowSchema = Schema.Struct({ +export const GitHubPullRequestListRowSchema = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, @@ -111,18 +122,28 @@ const FAILING_CHECK_CONCLUSIONS = new Set([ "STARTUP_FAILURE", ]); const PASSING_CHECK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]); +const NOT_RUN_CHECK_CONCLUSIONS = new Set(["SKIPPED", "NEUTRAL"]); -function nonEmpty(value: string | null | undefined): string | null { +/** Trims a host string and reports "the host left this out" as null. */ +export function nonEmptyText(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed.length > 0 ? trimmed : null; } +/** The `PullRequestActor` behind a `gh` author object, or null when unnamed. */ +export function normalizeActor( + raw: Schema.Schema.Type | null | undefined, +): { readonly login: string; readonly isBot: boolean } | null { + const login = nonEmptyText(raw?.login); + return login === null ? null : { login, isBot: raw?.is_bot === true || raw?.isBot === true }; +} + function normalizeState(raw: { readonly state?: string | null | undefined; readonly mergedAt?: string | null | undefined; }): PullRequestState { const state = raw.state?.trim().toUpperCase(); - if (nonEmpty(raw.mergedAt) !== null || state === "MERGED") { + if (nonEmptyText(raw.mergedAt) !== null || state === "MERGED") { return "merged"; } return state === "CLOSED" ? "closed" : "open"; @@ -143,10 +164,37 @@ function normalizeReviewDecision( } } +/** + * One check's own verdict. Check runs report `status` plus `conclusion`, legacy + * commit statuses only a `state`, so a run with no status is judged by whether + * its conclusion is a terminal one. + */ +export function normalizeCheckStatus( + check: Schema.Schema.Type, +): PullRequestCheckStatus { + const conclusion = ( + nonEmptyText(check.conclusion) ?? + nonEmptyText(check.state) ?? + "" + ).toUpperCase(); + if (FAILING_CHECK_CONCLUSIONS.has(conclusion)) { + return "failure"; + } + + const status = (nonEmptyText(check.status) ?? "").toUpperCase(); + const completed = + status.length > 0 ? status === "COMPLETED" : PASSING_CHECK_CONCLUSIONS.has(conclusion); + if (!completed) { + return "pending"; + } + + return NOT_RUN_CHECK_CONCLUSIONS.has(conclusion) ? "skipped" : "success"; +} + /** * Collapses `gh`'s per-check rollup into the one word the row renders. A check * that has not completed outranks the passing checks around it, and any hard - * failure outranks everything. + * failure outranks everything; a skipped check is nobody's problem. */ function normalizeChecksState( checks: ReadonlyArray> | null | undefined, @@ -157,15 +205,11 @@ function normalizeChecksState( let pending = false; for (const check of checks) { - const conclusion = (nonEmpty(check.conclusion) ?? nonEmpty(check.state) ?? "").toUpperCase(); - if (FAILING_CHECK_CONCLUSIONS.has(conclusion)) { + const status = normalizeCheckStatus(check); + if (status === "failure") { return "failure"; } - - const status = (nonEmpty(check.status) ?? "").toUpperCase(); - const completed = - status.length > 0 ? status === "COMPLETED" : PASSING_CHECK_CONCLUSIONS.has(conclusion); - if (!completed) { + if (status === "pending") { pending = true; } } @@ -173,10 +217,9 @@ function normalizeChecksState( return pending ? "pending" : "success"; } -function normalizeRow( +export function normalizeGitHubPullRequestListRow( raw: Schema.Schema.Type, ): GitHubPullRequestListRow { - const authorLogin = nonEmpty(raw.author?.login); const reviewDecision = normalizeReviewDecision(raw.reviewDecision); const checksState = normalizeChecksState(raw.statusCheckRollup); @@ -184,10 +227,7 @@ function normalizeRow( number: raw.number, title: raw.title, url: raw.url, - author: - authorLogin === null - ? null - : { login: authorLogin, isBot: raw.author?.is_bot === true || raw.author?.isBot === true }, + author: normalizeActor(raw.author), headBranch: raw.headRefName, baseBranch: raw.baseRefName, state: normalizeState(raw), @@ -197,18 +237,18 @@ function normalizeRow( createdAt: raw.createdAt, updatedAt: raw.updatedAt, reviewRequestedLogins: (raw.reviewRequests ?? []).flatMap((request) => { - const typename = nonEmpty(request.__typename); + const typename = nonEmptyText(request.__typename); if (typename !== null && typename !== "User") { return []; } - const login = nonEmpty(request.login); + const login = nonEmptyText(request.login); return login === null ? [] : [login]; }), ...(reviewDecision === undefined ? {} : { reviewDecision }), ...(checksState === undefined ? {} : { checksState }), labels: (raw.labels ?? []).flatMap((label) => { - const name = nonEmpty(label.name); - return name === null ? [] : [{ name, color: nonEmpty(label.color) }]; + const name = nonEmptyText(label.name); + return name === null ? [] : [{ name, color: nonEmptyText(label.color) }]; }), }; } @@ -218,6 +258,17 @@ const decodeRow = Schema.decodeUnknownExit(GitHubPullRequestListRowSchema); export const formatGitHubPullRequestListDecodeError = formatSchemaError; +/** + * One row in the shape `gh pr list --json` reports, or null when it is not in a + * shape we can use. The authored search reads through here too: its GraphQL + * nodes are reshaped into this and then decoded, so both listings normalise a + * state, a review decision and a check rollup exactly the same way. + */ +export function decodeGitHubPullRequestListRow(entry: unknown): GitHubPullRequestListRow | null { + const decoded = decodeRow(entry); + return Exit.isFailure(decoded) ? null : normalizeGitHubPullRequestListRow(decoded.value); +} + /** * Decodes `gh pr list --json` output. A row `gh` reports in a shape we cannot * use is dropped so one odd pull request never hides the rest; only a payload @@ -233,11 +284,10 @@ export function decodeGitHubPullRequestListJson( const rows: GitHubPullRequestListRow[] = []; for (const entry of payload.success) { - const decoded = decodeRow(entry); - if (Exit.isFailure(decoded)) { - continue; + const row = decodeGitHubPullRequestListRow(entry); + if (row !== null) { + rows.push(row); } - rows.push(normalizeRow(decoded.value)); } return Result.succeed(rows); } diff --git a/apps/server/src/pullRequest/gitLabMergeRequest.test.ts b/apps/server/src/pullRequest/gitLabMergeRequest.test.ts new file mode 100644 index 000000000..e63ba606b --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequest.test.ts @@ -0,0 +1,319 @@ +// @effect-diagnostics preferSchemaOverJson:off +import { assert, describe, it } from "@effect/vitest"; +import * as Result from "effect/Result"; + +import { + decodeGitLabAwardsJson, + decodeGitLabCommitsJson, + decodeGitLabDiffsJson, + decodeGitLabDiscussionsJson, + decodeGitLabMergeRequestListJson, + decodeGitLabNotesJson, + decodeGitLabProjectJson, +} from "./gitLabMergeRequest.ts"; + +const mergeRequest = (overrides: Record) => ({ + iid: 7, + title: "Tidy the toolbox", + web_url: "https://gitlab.com/acme/tools/-/merge_requests/7", + source_branch: "feature/tidy", + target_branch: "main", + state: "opened", + created_at: "2026-08-30T10:00:00Z", + updated_at: "2026-08-31T10:00:00Z", + ...overrides, +}); + +const success = (result: Result.Result): A => { + assert.ok(Result.isSuccess(result), "expected the payload to decode"); + return result.success; +}; + +describe("decodeGitLabMergeRequestListJson", () => { + it("reads the state, the draft flag and the pipeline, and skips a malformed row", () => { + const rows = success( + decodeGitLabMergeRequestListJson( + JSON.stringify([ + mergeRequest({ iid: 1, state: "opened", draft: true }), + mergeRequest({ iid: 2, state: "opened", merged_at: "2026-08-31T11:00:00Z" }), + mergeRequest({ iid: 3, head_pipeline: { status: "failed" } }), + mergeRequest({ iid: 4, head_pipeline: { status: "running" } }), + { iid: 5 }, + ]), + ), + ); + + assert.deepStrictEqual( + rows.map((row) => [row.number, row.state, row.isDraft, row.checksState]), + [ + [1, "open", true, undefined], + [2, "merged", false, undefined], + [3, "open", false, "failure"], + [4, "open", false, "pending"], + ], + ); + }); + + it("collects the reviewers a review is outstanding from", () => { + const rows = success( + decodeGitLabMergeRequestListJson( + JSON.stringify([mergeRequest({ reviewers: [{ id: 9, username: "hubot" }, { id: 10 }] })]), + ), + ); + + assert.deepStrictEqual(rows[0]?.reviewRequestedLogins, ["hubot"]); + }); +}); + +describe("decodeGitLabProjectJson", () => { + it("offers a squash and a rebase for a semi-linear project the reader may merge on", () => { + assert.deepStrictEqual( + success( + decodeGitLabProjectJson( + JSON.stringify({ + default_branch: "main", + merge_method: "rebase_merge", + squash_option: "default_on", + permissions: { project_access: { access_level: 30 }, group_access: null }, + }), + ), + ), + { canWrite: true, mergeMethods: ["squash", "rebase"], defaultBranch: "main" }, + ); + }); + + it("offers every method when the project names no strategy, and refuses a reporter the merge", () => { + assert.deepStrictEqual( + success( + decodeGitLabProjectJson( + JSON.stringify({ + default_branch: "trunk", + permissions: { project_access: { access_level: 20 } }, + }), + ), + ), + { canWrite: false, mergeMethods: ["merge", "squash", "rebase"], defaultBranch: "trunk" }, + ); + }); +}); + +describe("decodeGitLabDiscussionsJson", () => { + it("keeps the positioned discussions, sides them by the line they carry, and marks the reader's own", () => { + const threads = success( + decodeGitLabDiscussionsJson( + JSON.stringify([ + { + id: "d1", + notes: [ + { + id: 11, + body: "Deleted line", + created_at: "2026-08-31T10:00:00Z", + author: { username: "octocat" }, + resolved: true, + position: { + position_type: "text", + old_path: "a.ts", + new_path: "a.ts", + old_line: 12, + new_line: null, + }, + }, + { + id: 12, + body: "Agreed", + created_at: "2026-08-31T10:05:00Z", + author: { username: "hubot" }, + }, + ], + }, + { + id: "d2", + notes: [ + { + id: 13, + body: "Added line", + created_at: "2026-08-31T10:10:00Z", + author: { username: "hubot" }, + position: { + position_type: "text", + old_path: "b.ts", + new_path: "b.ts", + new_line: 4, + }, + }, + ], + }, + // A plain discussion, which the conversation already shows. + { id: "d3", notes: [{ id: 14, body: "Nice", created_at: "2026-08-31T10:15:00Z" }] }, + ]), + "octocat", + ), + ); + + assert.deepStrictEqual( + threads.map((thread) => [ + thread.id, + thread.path, + thread.side, + thread.line, + thread.isResolved, + ]), + [ + ["d1", "a.ts", "left", 12, true], + ["d2", "b.ts", "right", 4, false], + ], + ); + assert.deepStrictEqual( + threads[0]?.comments.map((comment) => [comment.id, comment.viewerIsAuthor]), + [ + ["11", true], + ["12", false], + ], + ); + }); +}); + +describe("decodeGitLabNotesJson", () => { + it("drops GitLab's own activity entries and the notes that opened a line discussion", () => { + const comments = success( + decodeGitLabNotesJson( + JSON.stringify([ + { id: 1, body: "assigned to @hubot", created_at: "2026-08-31T10:00:00Z", system: true }, + { id: 2, body: "Looks good", created_at: "2026-08-31T10:05:00Z" }, + { + id: 3, + body: "On this line", + created_at: "2026-08-31T10:10:00Z", + type: "DiffNote", + }, + { id: 4, body: " ", created_at: "2026-08-31T10:15:00Z" }, + ]), + null, + ), + ); + + assert.deepStrictEqual( + comments.map((comment) => [comment.id, comment.kind]), + [["2", "issue-comment"]], + ); + }); +}); + +describe("decodeGitLabCommitsJson", () => { + it("reads the commits oldest first, since GitLab lists them the other way", () => { + const commits = success( + decodeGitLabCommitsJson( + JSON.stringify([ + { id: "b2", title: "Second", committed_date: "2026-08-31T10:00:00Z" }, + { + id: "a1", + title: "First", + committed_date: "2026-08-30T10:00:00Z", + author_name: "Octo Cat", + }, + ]), + ), + ); + + assert.deepStrictEqual( + commits.map((commit) => [commit.oid, commit.authorLogin]), + [ + ["a1", "Octo Cat"], + ["b2", null], + ], + ); + }); +}); + +describe("decodeGitLabDiffsJson", () => { + it("assembles a unified patch and reports a file GitLab would not inline", () => { + const page = success( + decodeGitLabDiffsJson( + JSON.stringify([ + { + old_path: "a.ts", + new_path: "a.ts", + diff: "@@ -1 +1 @@\n-old\n+new", + }, + { old_path: "big.bin", new_path: "big.bin", too_large: true, diff: "" }, + { + old_path: "was.ts", + new_path: "now.ts", + renamed_file: true, + diff: "@@ -1 +1 @@\n-a\n+b\n", + }, + ]), + ), + ); + + assert.equal(page.truncated, true); + assert.equal(page.rawCount, 3); + assert.equal( + page.patch, + [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + "diff --git a/big.bin b/big.bin", + "--- a/big.bin", + "+++ b/big.bin", + "diff --git a/was.ts b/now.ts", + "rename from was.ts", + "rename to now.ts", + "--- a/was.ts", + "+++ b/now.ts", + "@@ -1 +1 @@", + "-a", + "+b", + "", + ].join("\n"), + ); + }); +}); + +describe("decodeGitLabAwardsJson", () => { + it("groups the awards it knows, marks the reader's own, and keys the note ones by their id", () => { + const awards = success( + decodeGitLabAwardsJson( + JSON.stringify({ + data: { + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "octocat" } }, + { name: "thumbsup", user: { username: "hubot" } }, + { name: "pizza", user: { username: "hubot" } }, + ], + }, + notes: { + nodes: [ + { + id: "gid://gitlab/DiffNote/42", + awardEmoji: { nodes: [{ name: "rocket", user: { username: "hubot" } }] }, + }, + { id: "gid://gitlab/Note/43", awardEmoji: { nodes: [] } }, + ], + }, + }, + }, + }, + }), + "octocat", + ), + ); + + assert.deepStrictEqual(awards.reactions, [ + { content: "thumbs-up", count: 2, viewerReacted: true }, + ]); + assert.deepStrictEqual(awards.reactionsByNoteId.get("42"), [ + { content: "rocket", count: 1, viewerReacted: false }, + ]); + assert.equal(awards.reactionsByNoteId.has("43"), false); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequest.ts b/apps/server/src/pullRequest/gitLabMergeRequest.ts new file mode 100644 index 000000000..64c361abb --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequest.ts @@ -0,0 +1,1119 @@ +import type * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestCheckStatus, + type PullRequestChecksState, + type PullRequestComment, + type PullRequestCommit, + type PullRequestLabel, + type PullRequestMergeability, + type PullRequestMergeMethod, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewCommentDraft, + type PullRequestReviewerCandidate, + type PullRequestReviewPosition, + type PullRequestReviewThread, + type PullRequestState, + type PullRequestThreadComment, +} from "@threadlines/contracts"; +import { decodeJsonResult } from "@threadlines/shared/schemaJson"; + +import type { ProviderRepositoryAccess } from "./PullRequestProvider.ts"; + +type DecodeFailure = Cause.Cause; + +/** + * 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 GitLabUserSchema = Schema.Struct({ + /** + * GitLab writes a merge request's reviewers as numeric ids and takes no + * usernames there, so the id travels beside the handle rather than being + * looked up again when a review is asked for. + */ + id: Schema.optional(Schema.NullOr(Schema.Int)), + username: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitLabPipelineSchema = 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 GitLabMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitLabUserSchema)), + source_branch: TrimmedNonEmptyString, + target_branch: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.NullOr(Schema.Boolean)), + work_in_progress: Schema.optional(Schema.NullOr(Schema.Boolean)), + merge_status: Schema.optional(Schema.NullOr(Schema.String)), + has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), + created_at: TrimmedNonEmptyString, + updated_at: TrimmedNonEmptyString, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(GitLabUserSchema))), + 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(GitLabPipelineSchema)), + /** + * Whether GitLab is holding the merge until the pipeline passes. + * `merge_when_pipeline_succeeds` is the field every version answers with; + * newer ones also carry `auto_merge_enabled`, which is the same fact under + * the name GitLab settled on, so either one saying yes is a yes. + */ + merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), + auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * How far the target branch has moved on since this one left it. It costs a + * walk of both branches, so GitLab withholds it unless + * `include_diverged_commits_count` asks, and answers it for one merge + * request only. + */ + diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), + diff_refs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + base_sha: Schema.optional(Schema.NullOr(Schema.String)), + head_sha: Schema.optional(Schema.NullOr(Schema.String)), + start_sha: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const GitLabNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitLabUserSchema)), + created_at: TrimmedNonEmptyString, + /** True for notes GitLab writes itself ("assigned to…"), which are events, not remarks. */ + system: Schema.optional(Schema.NullOr(Schema.Boolean)), + type: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A note inside a discussion, carrying its place in the diff. `resolved` lives + * on the note rather than on the discussion: GitLab calls a discussion resolved + * once its resolvable notes are. + */ +const GitLabDiscussionNoteSchema = Schema.Struct({ + ...GitLabNoteSchema.fields, + resolvable: Schema.optional(Schema.NullOr(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 GitLabDiscussionSchema = Schema.Struct({ + id: TrimmedNonEmptyString, + notes: Schema.optional(Schema.NullOr(Schema.Array(GitLabDiscussionNoteSchema))), +}); + +const GitLabCommitSchema = 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)), + author_name: Schema.optional(Schema.NullOr(Schema.String)), + author_email: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitLabDiffSchema = 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.NullOr(Schema.Boolean)), + renamed_file: Schema.optional(Schema.NullOr(Schema.Boolean)), + deleted_file: Schema.optional(Schema.NullOr(Schema.Boolean)), + diff: Schema.optional(Schema.NullOr(Schema.String)), + /** GitLab withholds the hunks for a file it considers too large, or collapsed. */ + too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), + collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const GitLabViewerSchema = Schema.Struct({ + username: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitLabApprovalsSchema = Schema.Struct({ + approved_by: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ user: Schema.optional(Schema.NullOr(GitLabUserSchema)) })), + ), + ), +}); + +/** + * A GitLab project settles on one merge strategy plus an optional squash, and + * states the reader's own role on it. + */ +const GitLabProjectSchema = Schema.Struct({ + default_branch: Schema.optional(Schema.NullOr(Schema.String)), + merge_method: Schema.optional(Schema.NullOr(Schema.String)), + squash_option: Schema.optional(Schema.NullOr(Schema.String)), + permissions: Schema.optional( + Schema.NullOr( + Schema.Struct({ + project_access: Schema.optional( + Schema.NullOr( + Schema.Struct({ access_level: Schema.optional(Schema.NullOr(Schema.Int)) }), + ), + ), + group_access: Schema.optional( + Schema.NullOr( + Schema.Struct({ access_level: Schema.optional(Schema.NullOr(Schema.Int)) }), + ), + ), + }), + ), + ), +}); + +/** One decoded merge request row, before the service attaches its project. */ +export interface GitLabMergeRequestRow { + 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 createdAt: string; + readonly updatedAt: string; + readonly reviewRequestedLogins: ReadonlyArray; + readonly labels: ReadonlyArray; + readonly checksState?: PullRequestChecksState; +} + +/** The three revisions a positioned comment is written against. */ +export interface GitLabDiffRefs { + readonly baseSha: string; + readonly headSha: string; + readonly startSha: string; +} + +export interface GitLabMergeRequestDetailRow extends GitLabMergeRequestRow { + readonly body: string; + readonly changedFiles: number; + readonly mergeability: PullRequestMergeability; + readonly mergedAt: string | null; + readonly closedAt: string | null; + /** Requested reviewers, keyed by the numeric id a reviewer write takes. */ + readonly reviewers: ReadonlyArray<{ readonly id: string; readonly login: string }>; + readonly checks: ReadonlyArray; + /** Null where GitLab named neither auto-merge field, which is not "off". */ + readonly autoMergeEnabled: boolean | null; + /** Null where GitLab did not count, which is not the same as "up to date". */ + readonly behindBy: number | null; + /** Null on a merge request with no revisions to place a comment against. */ + readonly diffRefs: GitLabDiffRefs | null; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** GitLab names no bot flag on the accounts it hands back with a merge request. */ +function toActor( + raw: Schema.Schema.Type | null | undefined, +): PullRequestActor | null { + const login = trimmed(raw?.username); + return login === null ? null : { login, isBot: false }; +} + +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 check. + return "unknown"; + } +} + +/** GitLab reports label names only, so there is no colour to carry. */ +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + 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": + case "canceled": + case "cancelling": + return "failure"; + // A pipeline waiting on a person has not run and is nobody's problem. + case "skipped": + case "manual": + case "scheduled": + return "skipped"; + default: + return "pending"; + } +} + +/** + * GitLab has no per-job check list on a merge request, so its head pipeline is + * the one check. The jobs behind it stay one click away through its 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 toChecksState( + raw: Schema.Schema.Type, +): PullRequestChecksState | undefined { + if (!raw.head_pipeline) { + return undefined; + } + switch (toPipelineStatus(raw.head_pipeline.status)) { + case "failure": + return "failure"; + case "pending": + return "pending"; + default: + return "success"; + } +} + +function toRow(raw: Schema.Schema.Type): GitLabMergeRequestRow { + const checksState = toChecksState(raw); + 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 === true || raw.work_in_progress === true, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + reviewRequestedLogins: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null ? [] : [login]; + }), + labels: toLabels(raw.labels), + ...(checksState === undefined ? {} : { checksState }), + }; +} + +function toDiffRefs( + raw: Schema.Schema.Type, +): GitLabDiffRefs | null { + const baseSha = trimmed(raw.diff_refs?.base_sha); + const headSha = trimmed(raw.diff_refs?.head_sha); + const startSha = trimmed(raw.diff_refs?.start_sha); + return baseSha === null || headSha === null || startSha === null + ? null + : { baseSha, headSha, startSha }; +} + +function toDetailRow( + raw: Schema.Schema.Type, +): GitLabMergeRequestDetailRow { + const autoMerge = + raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null + ? null + : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; + return { + ...toRow(raw), + body: raw.description ?? "", + changedFiles: toChangedFiles(raw.changes_count), + mergeability: toMergeability(raw), + mergedAt: trimmed(raw.merged_at), + closedAt: trimmed(raw.closed_at), + reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null || reviewer.id == null ? [] : [{ id: String(reviewer.id), login }]; + }), + checks: toChecks(raw), + autoMergeEnabled: autoMerge, + behindBy: raw.diverged_commits_count ?? null, + diffRefs: toDiffRefs(raw), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); +const decodeMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(GitLabNoteSchema); +const decodeDiscussionEntry = Schema.decodeUnknownExit(GitLabDiscussionSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(GitLabCommitSchema); +const decodeDiffEntry = Schema.decodeUnknownExit(GitLabDiffSchema); +const decodeUserEntry = Schema.decodeUnknownExit(GitLabUserSchema); +const decodeViewer = decodeJsonResult(GitLabViewerSchema); +const decodeApprovals = decodeJsonResult(GitLabApprovalsSchema); +const decodeProject = decodeJsonResult(GitLabProjectSchema); + +/** + * Malformed rows are skipped rather than failing the batch: one unexpected + * merge request must not blank the whole list. + */ +export function decodeGitLabMergeRequestListJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const rows: GitLabMergeRequestRow[] = []; + for (const entry of payload.success) { + const decoded = decodeMergeRequestEntry(entry); + if (Exit.isSuccess(decoded)) { + rows.push(toRow(decoded.value)); + } + } + return Result.succeed(rows); +} + +export function decodeGitLabMergeRequestDetailJson( + raw: string, +): Result.Result { + const payload = decodeMergeRequest(raw); + return Result.isSuccess(payload) + ? Result.succeed(toDetailRow(payload.success)) + : Result.fail(payload.failure); +} + +export function decodeGitLabViewerJson(raw: string): Result.Result { + const payload = decodeViewer(raw); + return Result.isSuccess(payload) + ? Result.succeed(trimmed(payload.success.username)) + : Result.fail(payload.failure); +} + +/** Who has approved, which is the only verdict GitLab records per reviewer. */ +export function decodeGitLabApprovalsJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodeApprovals(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + return Result.succeed( + (payload.success.approved_by ?? []).flatMap((approval) => { + const login = trimmed(approval.user?.username); + return login === null ? [] : [login]; + }), + ); +} + +/** The Developer role, which is the lowest one GitLab lets merge. */ +const GITLAB_DEVELOPER_ACCESS_LEVEL = 30; + +/** The order the detail surface offers the allowed merge methods in. */ +const MERGE_METHOD_ORDER = [ + "merge", + "squash", + "rebase", +] as const satisfies ReadonlyArray; + +/** + * The project's own settings. GitLab settles the strategy per project rather + * than offering all three per merge request: `merge_method` picks a merge + * commit, a semi-linear history or fast-forward, and squashing is a switch of + * its own. A project that names no strategy at all is not forbidding one, so + * all three are offered and GitLab refuses what it does not allow. + */ +export function decodeGitLabProjectJson( + raw: string, +): Result.Result { + const payload = decodeProject(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const row = payload.success; + const mergeMethod = trimmed(row.merge_method)?.toLowerCase() ?? null; + const squashOption = trimmed(row.squash_option)?.toLowerCase() ?? null; + const accessLevel = Math.max( + row.permissions?.project_access?.access_level ?? 0, + row.permissions?.group_access?.access_level ?? 0, + ); + const allowed = new Set(); + if (mergeMethod === "merge") { + allowed.add("merge"); + } + // Both a semi-linear and a fast-forward history are reached by rebasing. + if (mergeMethod === "rebase_merge" || mergeMethod === "ff") { + allowed.add("rebase"); + } + if ( + squashOption === "always" || + squashOption === "default_on" || + squashOption === "default_off" + ) { + allowed.add("squash"); + } + + return Result.succeed({ + canWrite: accessLevel >= GITLAB_DEVELOPER_ACCESS_LEVEL, + mergeMethods: + mergeMethod === null && allowed.size === 0 + ? MERGE_METHOD_ORDER + : MERGE_METHOD_ORDER.filter((method) => allowed.has(method)), + defaultBranch: trimmed(row.default_branch), + }); +} + +/** + * The conversation. System notes are GitLab's own activity entries, and a + * `DiffNote` opens a line discussion, which the Code tab reads separately. + */ +export function decodeGitLabNotesJson( + raw: string, + viewer: string | null, +): Result.Result, DecodeFailure> { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const viewerLogin = viewer?.toLowerCase() ?? null; + const comments: PullRequestComment[] = []; + for (const entry of payload.success) { + const decoded = decodeNoteEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const note = decoded.value; + const body = note.body ?? ""; + if (note.system === true || body.trim().length === 0) { + continue; + } + if (trimmed(note.type) === "DiffNote") { + continue; + } + const author = toActor(note.author); + comments.push({ + id: String(note.id), + kind: "issue-comment", + author, + body, + createdAt: note.created_at, + // GitLab does not link a note on its own; the merge request page carries it. + url: null, + reviewState: null, + reactions: [], + viewerIsAuthor: viewerLogin !== null && author?.login.toLowerCase() === viewerLogin, + }); + } + // ISO timestamps sort lexicographically; the conversation reads oldest first. + return Result.succeed( + comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ); +} + +/** + * Positioned discussions only. GitLab returns the whole conversation here, + * including the plain notes the Summary already shows, and only a positioned + * one belongs against a line of the diff. + */ +export function decodeGitLabDiscussionsJson( + raw: string, + viewer: string | null, +): Result.Result, DecodeFailure> { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const viewerLogin = viewer?.toLowerCase() ?? null; + const threads: PullRequestReviewThread[] = []; + for (const entry of payload.success) { + const decoded = decodeDiscussionEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const notes = (decoded.value.notes ?? []).filter((note) => note.system !== true); + const root = notes[0]; + const position = root?.position; + if (root === undefined || !position || trimmed(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 old file. + const side = position.new_line == null ? "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: decoded.value.id, + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolved === true, + // GitLab reports nothing equivalent to "written against a line that has + // since moved", so a thread the diff cannot place is worked out there. + isOutdated: false, + comments: notes.map((note): PullRequestThreadComment => { + const author = toActor(note.author); + return { + id: String(note.id), + author, + body: note.body ?? "", + createdAt: note.created_at, + url: null, + reactions: [], + viewerIsAuthor: viewerLogin !== null && author?.login.toLowerCase() === viewerLogin, + }; + }), + }); + } + return Result.succeed(threads); +} + +export function decodeGitLabCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of payload.success) { + const decoded = decodeCommitEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const commit = decoded.value; + const committedDate = trimmed(commit.committed_date) ?? trimmed(commit.created_at); + if (committedDate === null) { + continue; + } + commits.push({ + oid: commit.id, + messageHeadline: commit.title ?? "", + committedDate, + // GitLab records the git author, not the account, so the name stands in. + authorLogin: trimmed(commit.author_name) ?? trimmed(commit.author_email), + }); + } + // GitLab lists a merge request's commits newest first; the timeline reads + // oldest first. + return Result.succeed(commits.toReversed()); +} + +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; +} + +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}`, + }; +} + +/** + * GitLab returns hunks per file with no `diff --git` header, so the unified + * patch every diff viewer expects is assembled here. + */ +export function decodeGitLabDiffsJson( + raw: string, +): Result.Result { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of payload.success) { + const decoded = decodeDiffEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + const file = decoded.value; + const hunks = file.diff ?? ""; + if (hunks.length === 0) { + // A file GitLab declined to inline still belongs in the list, header only. + truncated = truncated || file.too_large === true || file.collapsed === true; + } + const { from, to } = diffHeaderPaths(file); + const header = [ + `diff --git a/${file.old_path} b/${file.new_path}`, + ...(file.new_file === true ? [`new file mode ${file.b_mode ?? "100644"}`] : []), + ...(file.deleted_file === true ? [`deleted file mode ${file.a_mode ?? "100644"}`] : []), + ...(file.renamed_file === true + ? [`rename from ${file.old_path}`, `rename to ${file.new_path}`] + : []), + `--- ${from}`, + `+++ ${to}`, + ].join("\n"); + sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.length === 0 ? "" : `${sections.join("\n").replace(/\n?$/, "\n")}`, + truncated, + rawCount: payload.success.length, + }); +} + +export interface GitLabReviewerCandidates { + readonly candidates: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row still counts. */ + readonly rawCount: number; +} + +/** + * The people with access to the project, which is the list GitLab fills its own + * reviewer field from. Nobody is marked requested here: who has been asked + * lives on the merge request, and only the caller holds both. + */ +export function decodeGitLabProjectUsersJson( + raw: string, +): Result.Result { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const candidates: PullRequestReviewerCandidate[] = []; + for (const entry of payload.success) { + const decoded = decodeUserEntry(entry); + if (Exit.isFailure(decoded) || decoded.value.id == null) { + continue; + } + const login = trimmed(decoded.value.username); + if (login === null) { + continue; + } + candidates.push({ + id: String(decoded.value.id), + kind: "user", + login, + name: trimmed(decoded.value.name), + requested: false, + }); + } + return Result.succeed({ candidates, rawCount: payload.success.length }); +} + +/** GitLab's award names for the eight reactions the contract carries. */ +const GITLAB_AWARD_BY_CONTENT: Readonly> = { + "thumbs-up": "thumbsup", + "thumbs-down": "thumbsdown", + laugh: "laughing", + hooray: "tada", + confused: "confused", + heart: "heart", + rocket: "rocket", + eyes: "eyes", +}; + +const CONTENT_BY_GITLAB_AWARD: Readonly> = + Object.fromEntries( + Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), + ) as Readonly>; + +export function gitLabAwardName(content: PullRequestReactionContent): string { + return GITLAB_AWARD_BY_CONTENT[content]; +} + +/** + * Awards on the merge request and on every note of it, in one read. The REST + * notes endpoint the conversation comes from carries no award at all, and + * asking per note would be a request each. + */ +export const GITLAB_AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: 100) { nodes { id awardEmoji { nodes { name user { username } } } } } + } + } +}`; + +const GitLabAwardNodesSchema = Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(GitLabUserSchema)), + }), + ), + ), + ), + ), + }), + ), +); + +const GitLabAwardPageSchema = Schema.Struct({ + data: Schema.Struct({ + project: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mergeRequest: Schema.optional( + Schema.NullOr( + Schema.Struct({ + awardEmoji: GitLabAwardNodesSchema, + notes: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + awardEmoji: GitLabAwardNodesSchema, + }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), + }), + ), + ), + }), +}); + +const decodeAwardPage = decodeJsonResult(GitLabAwardPageSchema); + +/** + * The awards on one subject, grouped the way a reaction chip is drawn. An award + * outside the eight the contract carries is left out rather than shown under a + * name the picker has no way to take back. + */ +function toReactions( + nodes: Schema.Schema.Type, + viewer: string | null, +): ReadonlyArray { + const viewerLogin = viewer?.toLowerCase() ?? null; + const groups = new Map(); + for (const node of nodes?.nodes ?? []) { + const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; + if (content === undefined) { + continue; + } + const group = groups.get(content) ?? { count: 0, viewerReacted: false }; + group.count += 1; + if (viewerLogin !== null && trimmed(node?.user?.username)?.toLowerCase() === viewerLogin) { + group.viewerReacted = true; + } + groups.set(content, group); + } + return [...groups].map(([content, group]) => ({ + content, + count: group.count, + viewerReacted: group.viewerReacted, + })); +} + +/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST notes carry. */ +function noteIdOf(gid: string | null | undefined): string | null { + const id = trimmed(gid)?.split("/").at(-1); + return id !== undefined && /^\d+$/.test(id) ? id : null; +} + +export interface GitLabAwards { + /** The merge request's own awards, which are the ones on its description. */ + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; +} + +export function decodeGitLabAwardsJson( + raw: string, + viewer: string | null, +): Result.Result { + const payload = decodeAwardPage(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const mergeRequest = payload.success.data.project?.mergeRequest; + const reactionsByNoteId = new Map>(); + for (const node of mergeRequest?.notes?.nodes ?? []) { + const id = noteIdOf(node?.id); + if (id === null) { + continue; + } + const reactions = toReactions(node?.awardEmoji, viewer); + if (reactions.length > 0) { + reactionsByNoteId.set(id, reactions); + } + } + return Result.succeed({ + reactions: toReactions(mergeRequest?.awardEmoji, viewer), + reactionsByNoteId, + }); +} + +const GitLabAwardSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(GitLabUserSchema)), +}); + +const decodeAwardEntry = Schema.decodeUnknownExit(GitLabAwardSchema); + +/** + * The reader's own award of one name on a subject, which is how a reaction is + * taken back: GitLab deletes an award by its id and cannot name one by emoji. + */ +export function decodeGitLabOwnAwardIdJson( + raw: string, + input: { readonly content: PullRequestReactionContent; readonly viewer: string }, +): Result.Result { + const payload = decodeUnknownList(raw); + if (!Result.isSuccess(payload)) { + return Result.fail(payload.failure); + } + const name = gitLabAwardName(input.content); + const viewerLogin = input.viewer.toLowerCase(); + for (const entry of payload.success) { + const decoded = decodeAwardEntry(entry); + if (Exit.isFailure(decoded)) { + continue; + } + if (trimmed(decoded.value.name)?.toLowerCase() !== name) { + continue; + } + if (trimmed(decoded.value.user?.username)?.toLowerCase() !== viewerLogin) { + continue; + } + return Result.succeed(decoded.value.id); + } + return Result.succeed(null); +} + +/** + * Where a line comment hangs, as GitLab's position object names it. A line the + * change added exists only in the new file, one it deleted only in the old, and + * a context line in both. + */ +function gitLabPositionLines(position: PullRequestReviewPosition): { + readonly old_line?: number; + readonly new_line?: number; +} { + switch (position.kind) { + case "added": + return { new_line: position.newLine }; + case "deleted": + return { old_line: position.oldLine }; + case "context": + return { old_line: position.oldLine, new_line: position.newLine }; + } +} + +const GitLabNoteBodySchema = Schema.Struct({ body: Schema.String }); +const encodeNoteBody = Schema.encodeSync(Schema.fromJsonString(GitLabNoteBodySchema)); + +/** The body of a plain note, a reply, and a review summary. */ +export function buildGitLabNoteBodyJson(body: string): string { + return encodeNoteBody({ body }); +} + +const GitLabResolutionSchema = Schema.Struct({ resolved: Schema.Boolean }); +const encodeResolution = Schema.encodeSync(Schema.fromJsonString(GitLabResolutionSchema)); + +export function buildGitLabResolutionJson(resolved: boolean): string { + return encodeResolution({ resolved }); +} + +const GitLabMergeRequestUpdateSchema = Schema.Struct({ + title: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), +}); +const encodeMergeRequestUpdate = Schema.encodeSync( + Schema.fromJsonString(GitLabMergeRequestUpdateSchema), +); + +/** + * Only the fields the caller asked to change: GitLab leaves out what it is not + * sent, and clears what it is sent empty, so a title corrected on its own must + * carry no description at all. + */ +export function buildGitLabMergeRequestUpdateJson(input: { + readonly title?: string; + readonly body?: string; +}): string { + return encodeMergeRequestUpdate({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }); +} + +const GitLabReviewerIdsSchema = Schema.Struct({ reviewer_ids: Schema.Array(Schema.Int) }); +const encodeReviewerIds = Schema.encodeSync(Schema.fromJsonString(GitLabReviewerIdsSchema)); + +/** + * GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` + * replaces the whole set, so the set already there is read first and the change + * applied to it. An id GitLab never named is dropped rather than written, so a + * stale client cannot rewrite the set around a number nobody chose. + */ +export function buildGitLabReviewerIdsJson(input: { + readonly current: ReadonlyArray; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; +}): string { + const ids = new Set(); + for (const id of input.current) { + const parsed = Number(id); + if (Number.isSafeInteger(parsed) && parsed > 0) { + ids.add(parsed); + } + } + for (const reviewer of input.reviewers) { + const parsed = Number(reviewer.id); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + continue; + } + if (input.requested) { + ids.add(parsed); + } else { + ids.delete(parsed); + } + } + return encodeReviewerIds({ reviewer_ids: [...ids] }); +} + +const GitLabDiscussionPositionSchema = Schema.Struct({ + body: Schema.String, + position: Schema.Struct({ + base_sha: Schema.String, + head_sha: Schema.String, + start_sha: Schema.String, + position_type: Schema.Literal("text"), + old_path: Schema.String, + new_path: Schema.String, + old_line: Schema.optionalKey(Schema.Int), + new_line: Schema.optionalKey(Schema.Int), + }), +}); +const encodeDiscussionPosition = Schema.encodeSync( + Schema.fromJsonString(GitLabDiscussionPositionSchema), +); + +/** + * One line comment as a positioned discussion. Both paths travel 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 it had before. + */ +export function buildGitLabDiscussionJson(input: { + readonly comment: PullRequestReviewCommentDraft; + readonly refs: GitLabDiffRefs; +}): string { + return encodeDiscussionPosition({ + body: input.comment.body, + position: { + base_sha: input.refs.baseSha, + head_sha: input.refs.headSha, + start_sha: input.refs.startSha, + position_type: "text", + old_path: input.comment.oldPath ?? input.comment.path, + new_path: input.comment.path, + ...gitLabPositionLines(input.comment.position), + }, + }); +} + +const GitLabGraphQlRequestSchema = Schema.Struct({ + query: Schema.String, + variables: Schema.Record(Schema.String, Schema.String), +}); +const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GitLabGraphQlRequestSchema)); + +/** A GraphQL request as `glab api graphql --input -` takes it. */ +export function buildGitLabGraphQlRequestJson(input: { + readonly query: string; + readonly variables: Readonly>; +}): string { + return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); +} diff --git a/apps/server/src/pullRequest/pullRequestDiff.test.ts b/apps/server/src/pullRequest/pullRequestDiff.test.ts new file mode 100644 index 000000000..7b6631558 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestDiff.test.ts @@ -0,0 +1,53 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { capPullRequestDiff } from "./pullRequestDiff.ts"; + +describe("capPullRequestDiff", () => { + const fileBlock = (path: string, padding: number) => + [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + "@@ -0,0 +1,1 @@", + `+${"x".repeat(padding)}`, + "", + ].join("\n"); + + it("leaves a patch under the cap alone", () => { + const patch = `${fileBlock("a.ts", 10)}${fileBlock("b.ts", 10)}`; + + assert.deepStrictEqual(capPullRequestDiff({ patch, truncated: false }), { + patch, + truncated: false, + }); + }); + + it("cuts an over-cap patch at the last whole file", () => { + const patch = `${fileBlock("a.ts", 40)}${fileBlock("b.ts", 40)}${fileBlock("c.ts", 40)}`; + const maxBytes = fileBlock("a.ts", 40).length + fileBlock("b.ts", 40).length + 10; + + const result = capPullRequestDiff({ patch, truncated: false, maxBytes }); + + assert.equal(result.truncated, true); + assert.equal(result.patch, `${fileBlock("a.ts", 40)}${fileBlock("b.ts", 40)}`); + }); + + it("drops the half-read file when the process runner truncated the patch", () => { + const patch = `${fileBlock("a.ts", 20)}diff --git a/b.ts b/b.ts\n--- a/b.ts\n`; + + const result = capPullRequestDiff({ patch, truncated: true }); + + assert.equal(result.truncated, true); + assert.equal(result.patch, fileBlock("a.ts", 20)); + }); + + it("cuts a single over-cap file at its last whole line", () => { + const patch = `${fileBlock("a.ts", 400)}`; + + const result = capPullRequestDiff({ patch, truncated: false, maxBytes: 60 }); + + assert.equal(result.truncated, true); + assert.ok(result.patch.length <= 60); + assert.ok(result.patch.endsWith("\n")); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestDiff.ts b/apps/server/src/pullRequest/pullRequestDiff.ts new file mode 100644 index 000000000..5343db17f --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestDiff.ts @@ -0,0 +1,36 @@ +import type { PullRequestDiffResult } from "@threadlines/contracts"; + +/** The patch cap the wire enforces; past it the tail is dropped. */ +export const PULL_REQUEST_DIFF_MAX_BYTES = 2 * 1024 * 1024; + +/** + * Holds a patch to the wire cap, whichever host produced it. The viewer parses + * whole files, so an over-cap or host-truncated patch is cut back to the last + * complete `diff --git` block instead of ending mid-hunk. A single file bigger + * than the cap has no such boundary, so it is cut at its last whole line. + */ +export function capPullRequestDiff(input: { + readonly patch: string; + readonly truncated: boolean; + readonly maxBytes?: number; +}): PullRequestDiffResult { + const maxBytes = input.maxBytes ?? PULL_REQUEST_DIFF_MAX_BYTES; + const buffer = Buffer.from(input.patch, "utf8"); + if (buffer.length <= maxBytes && !input.truncated) { + return { patch: input.patch, truncated: false }; + } + + const limit = Math.min(buffer.length, maxBytes); + const fileBoundary = buffer.lastIndexOf("\ndiff --git ", limit - 1, "utf8"); + if (fileBoundary >= 0) { + return { patch: buffer.subarray(0, fileBoundary + 1).toString("utf8"), truncated: true }; + } + + if (buffer.length <= maxBytes) { + return { patch: input.patch, truncated: true }; + } + + const lineBoundary = buffer.lastIndexOf("\n", limit - 1, "utf8"); + const cut = lineBoundary >= 0 ? lineBoundary + 1 : limit; + return { patch: buffer.subarray(0, cut).toString("utf8"), truncated: true }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 76923aa7c..86459f6b4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -69,6 +69,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import { AutomaticGitFetchSupervisorLive } from "./vcs/AutomaticGitFetchSupervisor.ts"; import * as GitAuthRemediationService from "./git/GitAuthRemediationService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -247,10 +248,22 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l Layer.provideMerge(SourceControlProviderRegistryLayerLive), ); -// Reads pull requests straight through `gh`, so it needs the CLI plus the -// project list; `ProjectionSnapshotQuery` comes from the orchestration layer -// merged in below it. -const PullRequestServiceLayerLive = PullRequestService.layer.pipe(Layer.provide(GitHubCli.layer)); +// One provider per host, each owning its own tool. Bitbucket reads over HTTP +// and needs the git driver its client resolves remotes with. +// `ProjectionSnapshotQuery` comes from the orchestration layer merged in below +// the service. +const PullRequestProviderRegistryLayerLive = PullRequestProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer).pipe( + Layer.provide(GitVcsDriver.layer), + Layer.provide(VcsDriverRegistryLayerLive), + ), + ), +); + +const PullRequestServiceLayerLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistryLayerLive), +); const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsProjectConfig.layer), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 394ece348..d0457124e 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -129,7 +129,24 @@ export interface BitbucketRepositoryLocator { readonly repoSlug: string; } +/** One raw Bitbucket response: the body as text, and the status it came with. */ +export interface BitbucketApiResponse { + readonly status: number; + readonly body: string; +} + export interface BitbucketApiShape { + /** + * One authenticated call, answered as text rather than decoded. `path` is + * either a path below the configured API base, or a whole URL Bitbucket + * handed back as the next page of a listing. A JSON body travels as text so + * it stays whatever the caller encoded. + */ + readonly request: (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + readonly path: string; + readonly body?: string; + }) => Effect.Effect; readonly probeAuth: Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; @@ -603,6 +620,38 @@ export const make = Effect.fn("makeBitbucketApi")(function* () { }); return BitbucketApi.of({ + request: (input) => + httpClient + .execute( + withAuth( + HttpClientRequest.make(input.method)( + input.path.startsWith("http") ? input.path : apiUrl(input.path), + ).pipe(HttpClientRequest.acceptJson, (request) => + input.body === undefined + ? request + : HttpClientRequest.bodyText(request, input.body, "application/json"), + ), + ), + ) + .pipe( + Effect.mapError((cause) => requestError("request", cause)), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.text.pipe( + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation: "request", + status: response.status, + detail: "Bitbucket returned a response body that could not be read.", + cause, + }), + ), + Effect.map((body) => ({ status: response.status, body })), + ) + : responseError("request", response), + ), + ), probeAuth: executeJson( "probeAuth", HttpClientRequest.get(apiUrl("/user")), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 5f174da85..1f3e49899 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -50,7 +50,15 @@ export interface GitHubCliShape { readonly execute: (input: { readonly cwd: string; readonly args: ReadonlyArray; + /** + * Written to the process's stdin and closed. Every GraphQL document, review + * payload, and reader-written body travels this way: argv shows up in + * process listings and is echoed back inside process-runner failures. + */ + readonly stdin?: string; readonly timeoutMs?: number; + /** Raises the process runner's default output ceiling; patches need it. */ + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -266,6 +274,8 @@ export const make = Effect.fn("makeGitHubCli")(function* () { cwd: input.cwd, env: THREADLINES_GITHUB_CLI_ENV, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index d065cf875..3e8fb03b3 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -52,7 +52,15 @@ export interface GitLabCliShape { readonly execute: (input: { readonly cwd: string; readonly args: ReadonlyArray; + /** + * Written to the process's stdin and closed. Every request body and + * reader-written note travels this way: argv shows up in process listings + * and is echoed back inside process-runner failures. + */ + readonly stdin?: string; readonly timeoutMs?: number; + /** Raises the process runner's default output ceiling; patches need it. */ + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listMergeRequests: (input: { @@ -281,6 +289,8 @@ export const make = Effect.fn("makeGitLabCli")(function* () { command: "glab", args: input.args, cwd: input.cwd, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }) .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index dc0ad8b50..273052812 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -72,7 +72,7 @@ function encodeAzureDevOpsPathSegment(segment: string): string { return encodeURIComponent(segment); } -function azureDevOpsOrganizationBaseFromRestApiUrl( +export function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { const rawUrl = trimOptionalString(value); @@ -104,29 +104,52 @@ function azureDevOpsOrganizationBaseFromRestApiUrl( } } -function normalizeAzureDevOpsPullRequestUrl( - raw: Schema.Schema.Type, -): string { - const webLink = trimOptionalString(raw._links?.web?.href); +/** + * The browser URL of one pull request, built from whatever Azure returned: the + * web link it sends, the repository's own page, or the organization the REST + * URL names. Empty when Azure said too little to place 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(/\/+$/u, "")}/pullrequest/${raw.pullRequestId}`; + return `${repositoryWebUrl.replace(/\/+$/u, "")}/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/ws.ts b/apps/server/src/ws.ts index 5956361c0..ca4cffe2f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1962,6 +1962,72 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { "rpc.aggregate": "pullRequests", }), + [WS_METHODS.pullRequestsDetail]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsActivity]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsDiff]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDiff, pullRequests.diff(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsRunAction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsSubmitReview]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsReplyToThread]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReplyToThread, + pullRequests.replyToThread(input), + { + "rpc.aggregate": "pullRequests", + }, + ), + [WS_METHODS.pullRequestsSetThreadResolution]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetThreadResolution, + pullRequests.setThreadResolution(input), + { "rpc.aggregate": "pullRequests" }, + ), + [WS_METHODS.pullRequestsSetReaction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsUpdate]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { + "rpc.aggregate": "pullRequests", + }), + [WS_METHODS.pullRequestsUpdateComment]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsUpdateComment, + pullRequests.updateComment(input), + { + "rpc.aggregate": "pullRequests", + }, + ), + [WS_METHODS.pullRequestsReviewerCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReviewerCandidates, + pullRequests.reviewerCandidates(input), + { "rpc.aggregate": "pullRequests" }, + ), + [WS_METHODS.pullRequestsRequestReviewers]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsRequestReviewers, + pullRequests.requestReviewers(input), + { "rpc.aggregate": "pullRequests" }, + ), [WS_METHODS.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", diff --git a/apps/web/src/components/PageTitlebar.tsx b/apps/web/src/components/PageTitlebar.tsx new file mode 100644 index 000000000..c9042b6e9 --- /dev/null +++ b/apps/web/src/components/PageTitlebar.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +import { ELECTRON_HEADER_HEIGHT_CLASS } from "../desktopChrome"; +import { isElectron } from "../env"; +import { cn } from "../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; +import { SidebarOpenTrigger } from "./ui/sidebar"; + +/** + * The strip across the top of a full-page surface (General chats, Pull + * requests, Usage, Settings). + * + * On desktop the window controls overlay the top of the content area, so every + * page needs a draggable strip of titlebar height above its scroll container -- + * otherwise the page's scrollbar runs underneath the minimize/close buttons and + * there is nothing to grab to move the window. + * + * On the web below the `md` breakpoint the sidebar is a sheet, and the only + * way to reach it is a trigger in the page itself. A thread carries one in its + * header; a page with none strands the reader. So here the strip is that + * header: the trigger and the page name, the same row a thread shows. Pages + * that already draw their own mobile header (a Back arrow) opt out with + * `mobile="none"` rather than showing two. + */ +export function PageTitlebar({ + label, + mobile = "sidebar", + children, +}: { + /** Small muted page name on desktop; the header title on a phone. */ + readonly label?: string; + /** What the strip is on a phone: the sidebar trigger row, or nothing. */ + readonly mobile?: "sidebar" | "none"; + /** Optional extra content, laid out after the label. */ + readonly children?: ReactNode; +}) { + if (!isElectron) { + if (mobile === "none") { + return null; + } + return ( +
+ + {label ? ( + {label} + ) : null} + {children} +
+ ); + } + return ( +
+ + {label ? ( + {label} + ) : null} + {children} +
+ ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 652912ae0..ea908b64c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -53,7 +53,30 @@ import type { VcsStatusLocalResult, VcsStatusResult, } from "./git.ts"; -import type { PullRequestListInput, PullRequestListResult } from "./pullRequest.ts"; +import type { + PullRequestActionInput, + PullRequestActionResult, + PullRequestActivity, + PullRequestActivityInput, + PullRequestCommentInput, + PullRequestCommentResult, + PullRequestCommentUpdateInput, + PullRequestDetail, + PullRequestDetailInput, + PullRequestDiffInput, + PullRequestDiffResult, + PullRequestListInput, + PullRequestListResult, + PullRequestReactionInput, + PullRequestRef, + PullRequestReviewerCandidateList, + PullRequestReviewerRequestInput, + PullRequestReviewInput, + PullRequestReviewResult, + PullRequestThreadReplyInput, + PullRequestThreadResolutionInput, + PullRequestUpdateInput, +} from "./pullRequest.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { UsageSummary, UsageSummaryInput } from "./usage.ts"; import type { @@ -1361,6 +1384,19 @@ export interface EnvironmentApi { }; pullRequests: { list: (input: PullRequestListInput) => Promise; + detail: (input: PullRequestDetailInput) => Promise; + activity: (input: PullRequestActivityInput) => Promise; + diff: (input: PullRequestDiffInput) => Promise; + comment: (input: PullRequestCommentInput) => Promise; + runAction: (input: PullRequestActionInput) => Promise; + submitReview: (input: PullRequestReviewInput) => Promise; + replyToThread: (input: PullRequestThreadReplyInput) => Promise; + setThreadResolution: (input: PullRequestThreadResolutionInput) => Promise; + setReaction: (input: PullRequestReactionInput) => Promise; + update: (input: PullRequestUpdateInput) => Promise; + updateComment: (input: PullRequestCommentUpdateInput) => Promise; + reviewerCandidates: (input: PullRequestRef) => Promise; + requestReviewers: (input: PullRequestReviewerRequestInput) => Promise; }; orchestration: { dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index def50b6c1..33c17a04f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -39,12 +39,25 @@ export const PullRequestLabel = Schema.Struct({ }); export type PullRequestLabel = typeof PullRequestLabel.Type; +/** + * Where a row came from. A workspace row is one of the repositories the + * workspace points at; an authored row came from a search for the viewer's own + * pull requests and may name a repository no project here has checked out. + */ +export const PullRequestListEntryOrigin = Schema.Literals(["workspace", "authored"]); +export type PullRequestListEntryOrigin = typeof PullRequestListEntryOrigin.Type; + /** * One pull request as the pull requests page renders it: the host's fields plus * the project it belongs to and how it relates to the signed-in viewer. */ export const PullRequestListEntry = Schema.Struct({ provider: SourceControlProviderKind, + /** + * The project whose checkout runs the host's tool for this row. An authored + * row borrows a project on the same host, so it does not name the row's own + * repository. + */ projectId: ProjectId, projectTitle: TrimmedNonEmptyString, /** `owner/name`. */ @@ -68,6 +81,7 @@ export const PullRequestListEntry = Schema.Struct({ /** Absent when there are no checks, or when checks were not requested. */ checksState: Schema.optionalKey(PullRequestChecksState), labels: Schema.Array(PullRequestLabel), + origin: PullRequestListEntryOrigin, }); export type PullRequestListEntry = typeof PullRequestListEntry.Type; @@ -95,6 +109,11 @@ export const PullRequestListInput = Schema.Struct({ projectId: Schema.optionalKey(ProjectId), /** Drops the cached result for this listing before reading. */ force: Schema.optionalKey(Schema.Boolean), + /** + * Whether the viewer's own pull requests on repositories the workspace does + * not point at join the listing. On unless a caller turns it off. + */ + includeAuthored: Schema.optionalKey(Schema.Boolean), }); export type PullRequestListInput = typeof PullRequestListInput.Type; @@ -117,3 +136,459 @@ export class PullRequestServiceError extends Schema.TaggedError; + +type ChangeRequestProviderKind = (typeof CHANGE_REQUEST_PROVIDER_KINDS)[number]; + +/** A recorded provider, but only as one of the hosts with change requests. */ +export function toChangeRequestProviderKind( + value: string | undefined, +): ChangeRequestProviderKind | null { + const kinds: ReadonlyArray = CHANGE_REQUEST_PROVIDER_KINDS; + return kinds.includes(value ?? "") ? (value as ChangeRequestProviderKind) : null; +} + +/** + * How a host names the repository a change request lives in, which is the name + * the server asks with and the client compares rows against. + * + * `displayName` is the whole path below the host, which is what a nested GitLab + * group needs; `owner/name` is the two-segment fallback for an identity + * recorded before that field existed. Azure DevOps is the exception: + * `az repos pr` takes a repository's own name and reads the organisation and + * project from the checkout it detects, so the recorded `org/project/_git/repo` + * path is reduced to its last segment. + * + * Null for an identity that names no repository, and for a host with no change + * requests to read. + */ +export function changeRequestRepositoryName( + identity: RepositoryIdentity | null | undefined, +): string | null { + const provider = toChangeRequestProviderKind(identity?.provider); + if (!identity || provider === null) { + return null; + } + + const displayName = identity.displayName?.trim() ?? ""; + const owner = identity.owner?.trim() ?? ""; + const name = identity.name?.trim() ?? ""; + + if (provider === "azure-devops") { + if (name.length > 0) { + return name; + } + const segments = displayName.split("/").filter((segment) => segment !== "_git"); + return segments.at(-1)?.trim() || null; + } + if (displayName.includes("/")) { + return displayName; + } + return owner.length > 0 && name.length > 0 ? `${owner}/${name}` : null; +} export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; From 777f70e1d631a8372879c8fdc6282646197558a3 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:03:37 -0400 Subject: [PATCH 3/5] feat(web): pull requests page, detail panel, and thread tab A Pull requests page with open, merged, and closed listings grouped by Needs you, Yours, and Others, search, filters, and sort kept in the URL. A detail panel with Summary, Code, and Timeline tabs: review conversations pinned to diff lines, a pending review that sends as one, merge, close, reopen, draft, update branch, auto-merge, and edits, each gated on what the host and the viewer's rights allow. The same panel opens as a Pull request tab beside a thread. Hand-offs write fix, explain, ask, and resolve-conflicts prompts into a thread's composer. Pages draw a phone header with the sidebar toggle, and the diff viewer warms up when a pull request opens. --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.tsx | 55 +- .../src/components/ChatsDestinationView.tsx | 4 +- apps/web/src/components/CommandPalette.tsx | 73 +- .../src/components/DesktopPageTitlebar.tsx | 45 - apps/web/src/components/DiffPanel.tsx | 134 +- .../components/chat/RightPanelTabStrip.tsx | 3 +- .../chat/rightPanelLauncherState.test.ts | 28 + .../chat/rightPanelLauncherState.ts | 40 + .../components/diffs/AnnotatedDiffView.tsx | 156 +++ .../diffs/DiffCommentAnnotation.tsx | 144 ++ .../components/diffs/fileDiffPresentation.tsx | 175 +++ .../components/diffs/useDiffWorkerReady.ts | 27 + .../LazyPullRequestDetailPanel.tsx | 31 + .../pull-requests/PullRequestCodeTab.tsx | 543 ++++++++ .../PullRequestDetailPanel.browser.tsx | 621 +++++++++ .../pull-requests/PullRequestDetailPanel.tsx | 1213 +++++++++++++++++ .../pull-requests/PullRequestFilters.tsx | 270 ++++ .../PullRequestMarkdownEditor.tsx | 119 ++ .../pull-requests/PullRequestReactions.tsx | 183 +++ .../PullRequestReviewAnnotations.tsx | 384 ++++++ .../pull-requests/PullRequestReviewBar.tsx | 205 +++ .../PullRequestReviewerPicker.tsx | 155 +++ .../pull-requests/PullRequestSummaryTab.tsx | 592 ++++++++ .../pull-requests/PullRequestTimelineTab.tsx | 327 +++++ .../PullRequestsView.browser.tsx | 217 ++- .../pull-requests/PullRequestsView.tsx | 733 +++++++--- .../pullRequestHandoffs.logic.test.ts | 163 +++ .../pullRequestHandoffs.logic.ts | 205 +++ .../pull-requests/pullRequestPresentation.tsx | 249 ++++ .../pull-requests/pullRequestReviewStore.ts | 131 ++ .../pull-requests/pullRequests.logic.test.ts | 587 ++++++++ .../pull-requests/pullRequests.logic.ts | 1095 ++++++++++++++- .../source-control/SourceControlPanel.tsx | 55 +- apps/web/src/components/ui/page-tabs.tsx | 55 +- apps/web/src/components/usage/UsageView.tsx | 4 +- apps/web/src/diffRouteSearch.test.ts | 29 +- apps/web/src/diffRouteSearch.ts | 24 +- apps/web/src/environmentApi.ts | 13 + apps/web/src/hooks/useHandleNewThread.ts | 14 + apps/web/src/index.css | 13 + apps/web/src/lib/pullRequestsReactQuery.ts | 324 ++++- apps/web/src/localApi.test.ts | 7 + apps/web/src/rightPanelTabs.test.ts | 15 +- apps/web/src/rightPanelTabs.ts | 16 +- .../routes/_chat.$environmentId.$threadId.tsx | 85 ++ apps/web/src/routes/_chat.pull-requests.tsx | 72 +- apps/web/src/routes/settings.tsx | 6 +- apps/web/src/rpc/wsRpcClient.ts | 38 + apps/web/src/sourceControlPresentation.ts | 1 + pnpm-lock.yaml | 218 +-- 51 files changed, 9358 insertions(+), 540 deletions(-) delete mode 100644 apps/web/src/components/DesktopPageTitlebar.tsx create mode 100644 apps/web/src/components/diffs/AnnotatedDiffView.tsx create mode 100644 apps/web/src/components/diffs/DiffCommentAnnotation.tsx create mode 100644 apps/web/src/components/diffs/fileDiffPresentation.tsx create mode 100644 apps/web/src/components/diffs/useDiffWorkerReady.ts create mode 100644 apps/web/src/components/pull-requests/LazyPullRequestDetailPanel.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestCodeTab.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestDetailPanel.browser.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestDetailPanel.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestFilters.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestMarkdownEditor.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestReactions.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestReviewAnnotations.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestReviewBar.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestReviewerPicker.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestSummaryTab.tsx create mode 100644 apps/web/src/components/pull-requests/PullRequestTimelineTab.tsx create mode 100644 apps/web/src/components/pull-requests/pullRequestHandoffs.logic.test.ts create mode 100644 apps/web/src/components/pull-requests/pullRequestHandoffs.logic.ts create mode 100644 apps/web/src/components/pull-requests/pullRequestPresentation.tsx create mode 100644 apps/web/src/components/pull-requests/pullRequestReviewStore.ts diff --git a/apps/web/package.json b/apps/web/package.json index cc77a49e5..3305adf45 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,8 @@ "react": "19.2.8", "react-dom": "19.2.8", "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.15" diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index bf1cb2e64..4a3ed918f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,9 +22,11 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkGfm from "remark-gfm"; import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { MessageCopyButton } from "./chat/MessageCopyButton"; @@ -102,10 +104,26 @@ interface ChatMarkdownProps { isStreaming?: boolean; skills?: ReadonlyArray>; searchHighlightQuery?: string | undefined; + /** + * Render the raw HTML GitHub allows in a pull request body or comment + * (tables with markup in cells, images, details). Chat stays markdown-only, + * where an agent's stray tag is safer shown than interpreted. + */ + html?: "github" | undefined; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +/** + * GitHub's own allowlist, which is what the host already applied to the text + * before it reached us. Anything outside it is unwrapped to its children, so a + * `` still reads as its date and a `` as its image. + */ +const GITHUB_HTML_REHYPE_PLUGINS: NonNullable = [ + rehypeRaw, + [rehypeSanitize, defaultSchema], +]; + const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -625,6 +643,19 @@ function MarkdownPre({ node: _node, children, ...props }: MarkdownRendererProps< ); } +/** + * An image the text points at. A host bot links images that later go missing, + * and a broken-image glyph says nothing; the alt text at least says what was + * meant to be there. + */ +function MarkdownImage({ alt, src, ...rest }: React.ComponentProps<"img">) { + const [failed, setFailed] = useState(false); + if (failed || !src) { + return alt ? {alt} : null; + } + return {alt setFailed(true)} {...rest} />; +} + /** * The renderer map handed to react-markdown, built once. react-markdown uses * these functions as element types, so rebuilding the map would remount every @@ -635,6 +666,7 @@ const MARKDOWN_COMPONENTS: Components = { p: MarkdownParagraph, li: MarkdownListItem, a: MarkdownAnchor, + img: MarkdownImage, code: MarkdownCode, pre: MarkdownPre, }; @@ -1260,6 +1292,7 @@ function ChatMarkdownDocument({ isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, searchHighlightQuery, + html, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); // Null unless both halves of the identity are here: a transcript rendered @@ -1366,6 +1399,7 @@ function ChatMarkdownDocument({ @@ -1390,6 +1424,7 @@ const MarkdownBlock = memo(function MarkdownBlock({ isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, searchHighlightQuery, + html, }: ChatMarkdownProps) { // Lets React drop intermediate parses when deltas outpace rendering // (older CPUs) instead of parsing every 50ms server flush. A settled block's @@ -1408,6 +1443,7 @@ const MarkdownBlock = memo(function MarkdownBlock({ isStreaming={isStreaming} skills={skills} searchHighlightQuery={searchHighlightQuery} + html={html} /> ); }); @@ -1420,13 +1456,16 @@ function ChatMarkdownBody({ isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, searchHighlightQuery, + html, }: ChatMarkdownProps) { // Blocks are the unit of rendering whether or not the message is streaming: // a streaming message re-parses only its growing tail, and when it stops // streaming nothing changes but the tail's `isStreaming` prop. Index keys are // the correct identity here: streaming only appends blocks, and keying by // content would remount the tail on every delta. - const blocks = splitMarkdownBlocks(text); + // Raw HTML can span what the splitter takes for several blocks (a + // `
` around paragraphs), so an HTML-bearing document parses whole. + const blocks = html === "github" ? [text] : splitMarkdownBlocks(text); const tailIndex = blocks.length - 1; /* oxlint-disable react/no-array-index-key -- streaming only appends blocks; index is the stable identity */ return ( @@ -1441,6 +1480,7 @@ function ChatMarkdownBody({ isStreaming={isStreaming && index === tailIndex} skills={skills} searchHighlightQuery={searchHighlightQuery} + html={html} /> ))} @@ -1456,6 +1496,7 @@ function ChatMarkdown({ isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, searchHighlightQuery, + html, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); // Boots the highlighting worker and its theme while the page is idle, so the @@ -1469,7 +1510,14 @@ function ChatMarkdown({ : [{ type: "markdown" as const, key: "markdown:0", text }]; return ( -
+
{segments.map((segment, index) => { if (segment.type === "visualization") { return environmentId && threadId ? ( @@ -1492,6 +1540,7 @@ function ChatMarkdown({ isStreaming={isStreaming && index === segments.length - 1} skills={skills} searchHighlightQuery={searchHighlightQuery} + html={html} /> ); })} diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx index e3b99a79d..03f833bdc 100644 --- a/apps/web/src/components/ChatsDestinationView.tsx +++ b/apps/web/src/components/ChatsDestinationView.tsx @@ -16,7 +16,7 @@ import { } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; import { formatRelativeTimeLabel } from "../timestampFormat"; -import { DesktopPageTitlebar } from "./DesktopPageTitlebar"; +import { PageTitlebar } from "./PageTitlebar"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { PROVIDER_OPTIONS } from "../session-logic"; import { resolveThreadStatusPill } from "./Sidebar.logic"; @@ -126,7 +126,7 @@ export function ChatsDestinationView() { return (
- + {/* The pane-wide element scrolls so the scrollbar hugs the pane's edge (like Settings); the reading column centers inside it. */}
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 961a99b0d..7d91df361 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,12 @@ "use client"; -import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@threadlines/client-runtime"; +import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { DEFAULT_NEW_THREAD_RUNTIME_MODE, type EnvironmentId, @@ -86,7 +92,14 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { waitForProjectInStore } from "../lib/waitForProject"; import { getLatestThreadForProject } from "../lib/threadSort"; import { threadSearchQueryOptions, type ThreadSearchTarget } from "../lib/threadSearchReactQuery"; -import { usePullRequestEnvironments } from "../lib/pullRequestsReactQuery"; +import { + PULL_REQUEST_COUNT_REFETCH_INTERVAL_MS, + usePullRequestEnvironments, + usePullRequestLists, +} from "../lib/pullRequestsReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; +import { resolveThreadPullRequest } from "./pull-requests/pullRequests.logic"; +import { focusRightPanelTab, rightPanelTabSearchParams } from "../rightPanelTabs"; import { cn, isMacPlatform, @@ -507,6 +520,41 @@ function OpenCommandPaletteDialog() { const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); const pullRequestEnvironments = usePullRequestEnvironments(); + // The active thread's pull request, so the palette can offer its tab. Both + // reads are the sidebar's: the same refcounted git status and the same open + // listing key, and neither runs on a thread with no branch to match. + const activeThreadProject = activeThread + ? (projects.find( + (project) => + project.environmentId === activeThread.environmentId && + project.id === activeThread.projectId, + ) ?? null) + : null; + const activeThreadHasBranch = (activeThread?.branch ?? null) !== null; + const activeThreadGitStatus = useGitStatus({ + environmentId: activeThreadHasBranch ? (activeThread?.environmentId ?? null) : null, + cwd: + activeThreadHasBranch && activeThread && activeThreadProject + ? resolveThreadWorkingCwd({ + projectCwd: activeThreadProject.cwd, + worktreePath: activeThread.worktreePath, + effectiveCwd: activeThread.effectiveCwd, + }) + : null, + }); + const activeThreadOpenPullRequests = usePullRequestLists({ + state: "open", + refetchIntervalMs: PULL_REQUEST_COUNT_REFETCH_INTERVAL_MS, + enabled: activeThreadHasBranch, + }); + const activeThreadPullRequest = activeThread + ? resolveThreadPullRequest({ + thread: activeThread, + gitStatus: activeThreadGitStatus.data, + openEntries: activeThreadOpenPullRequests.entries, + projects, + }) + : null; const addProjectEnvironmentOptions = useMemo(() => { const options: AddProjectEnvironmentOption[] = []; @@ -1616,6 +1664,27 @@ function OpenCommandPaletteDialog() { }); } + // Only where there is one to open: an action that lands on "this branch has + // no pull request" is a dead end the palette should not offer. + if (activeThreadPullRequest && activeThread) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:thread-pull-request", + searchTerms: ["pull request", "pr", "review", "github", `#${activeThreadPullRequest.number}`], + title: `Open pull request #${activeThreadPullRequest.number}`, + icon: , + run: async () => { + focusRightPanelTab(scopedThreadKey(threadRef), "pullRequest"); + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (previous) => rightPanelTabSearchParams(previous, "pullRequest"), + }); + }, + }); + } + actionItems.push({ kind: "action", value: "action:usage", diff --git a/apps/web/src/components/DesktopPageTitlebar.tsx b/apps/web/src/components/DesktopPageTitlebar.tsx deleted file mode 100644 index 37dfdd5a7..000000000 --- a/apps/web/src/components/DesktopPageTitlebar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { ReactNode } from "react"; - -import { ELECTRON_HEADER_HEIGHT_CLASS } from "../desktopChrome"; -import { isElectron } from "../env"; -import { cn } from "../lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; -import { SidebarOpenTrigger } from "./ui/sidebar"; - -/** - * The desktop window's titlebar strip for full-page surfaces. - * - * On desktop the window controls overlay the top of the content area, so every - * page needs a draggable strip of titlebar height above its scroll container -- - * otherwise the page's scrollbar runs underneath the minimize/close buttons and - * there is nothing to grab to move the window. Renders nothing outside the - * desktop app. - */ -export function DesktopPageTitlebar({ - label, - children, -}: { - /** Small muted page name, matching the other pages' strips. */ - readonly label?: string; - /** Optional extra content, laid out after the label. */ - readonly children?: ReactNode; -}) { - if (!isElectron) { - return null; - } - return ( -
- - {label ? ( - {label} - ) : null} - {children} -
- ); -} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1ab81e6de..15acf47b3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,7 +6,6 @@ import { type ContextMenuItem, TurnId } from "@threadlines/contracts"; import type { DiffRenderMode } from "@threadlines/contracts/settings"; import { ChevronDownIcon, - ChevronRightIcon, ChevronUpIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, @@ -58,6 +57,12 @@ import { sumDiffFileStats, } from "./DiffPanel.logic"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { + FileDiffHeader, + buildFileDiffRenderKey, + getFileDiffStatusBadge, + resolveFileDiffPath, +} from "./diffs/fileDiffPresentation"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { SourceControlIcon } from "./Icons"; import { @@ -87,49 +92,6 @@ import { TooltipWrapper } from "./ui/tooltip"; type DiffThemeType = "light" | "dark"; -function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; -} - -function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { - return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; -} - -/** Rename source path, only when it differs from the displayed path. */ -function resolveFileDiffPrevPath(fileDiff: FileDiffMetadata): string | null { - const raw = fileDiff.prevName; - if (!raw) return null; - const stripped = raw.startsWith("a/") || raw.startsWith("b/") ? raw.slice(2) : raw; - return stripped === resolveFileDiffPath(fileDiff) ? null : stripped; -} - -/** Matches workingTreeFileStatusClassName in SourceControlPanel: green added, - * red deleted, amber modified, so the tree and the diff cards speak one - * color language. */ -function getFileDiffStatusBadge(fileDiff: FileDiffMetadata): { - readonly label: string; - readonly className: string; -} { - switch (fileDiff.type) { - case "new": - return { label: "A", className: "border-success/25 bg-success/8 text-success-foreground" }; - case "deleted": - return { - label: "D", - className: "border-destructive/25 bg-destructive/8 text-destructive-foreground", - }; - case "rename-pure": - case "rename-changed": - return { label: "R", className: "border-warning/25 bg-warning/8 text-warning-foreground" }; - default: - return { label: "M", className: "border-warning/25 bg-warning/8 text-warning-foreground" }; - } -} - /** * The panel unmounts whenever the rail swaps back to source control, so the * collapse choices live here, keyed by thread + diff source, to survive the @@ -1684,75 +1646,15 @@ export default function DiffPanel({ mode = "inline", onClose, embedded = false } { - const badge = getFileDiffStatusBadge(fileDiff); - const fileStat = fileStatByKey.get(fileKey); - const pathSegments = filePath.split("/"); - const fileName = pathSegments.at(-1) ?? filePath; - const fileDirectory = pathSegments.slice(0, -1).join("/"); - const prevPath = resolveFileDiffPrevPath(fileDiff); - const prevFileName = prevPath - ? (prevPath.split("/").at(-1) ?? prevPath) - : null; - return ( -
- - - - - {badge.label} - - - {prevFileName && prevFileName !== fileName ? ( - - {prevFileName} → - - ) : null} - {fileDirectory ? ( - - {fileDirectory}/ - - ) : null} - - {fileName} - - - {fileStat ? ( - - - - ) : null} + renderCustomHeader={() => ( + toggleDiffFileCollapsed(fileKey)} + stat={fileStatByKey.get(fileKey) ?? null} + interactiveTitle + trailing={ -
- ); - }} + } + /> + )} options={{ collapsed, diffStyle: effectiveDiffRenderMode === "split" ? "split" : "unified", diff --git a/apps/web/src/components/chat/RightPanelTabStrip.tsx b/apps/web/src/components/chat/RightPanelTabStrip.tsx index 4b9f3916c..5cb4143b7 100644 --- a/apps/web/src/components/chat/RightPanelTabStrip.tsx +++ b/apps/web/src/components/chat/RightPanelTabStrip.tsx @@ -17,7 +17,7 @@ * tab you were just on is worse than losing its name, which a tooltip gives * back. Scrolling is still there underneath, for when even the icons overflow. */ -import { BotIcon, FileDiffIcon, PlusIcon, XIcon } from "lucide-react"; +import { BotIcon, FileDiffIcon, GitPullRequestIcon, PlusIcon, XIcon } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; @@ -37,6 +37,7 @@ import { export const RIGHT_PANEL_TAB_ICONS: Readonly> = { sourceControl: SourceControlIcon, diff: FileDiffIcon, + pullRequest: GitPullRequestIcon, agents: BotIcon, }; diff --git a/apps/web/src/components/chat/rightPanelLauncherState.test.ts b/apps/web/src/components/chat/rightPanelLauncherState.test.ts index 237aa4db7..e3a68f769 100644 --- a/apps/web/src/components/chat/rightPanelLauncherState.test.ts +++ b/apps/web/src/components/chat/rightPanelLauncherState.test.ts @@ -99,6 +99,34 @@ describe("buildRightPanelLauncherStates", () => { expect(states.diff).toEqual({ description: "No changes to review.", empty: true }); }); + it("names the branch's pull request, and dims the row when it has none", () => { + const named = buildRightPanelLauncherStates({ + workingTreeFileCount: 0, + diffHasExplicitTarget: false, + pullRequest: { number: 123, state: "open", isDraft: true }, + ...EMPTY_THREAD, + }); + expect(named.pullRequest).toEqual({ description: "#123 · Draft", empty: false }); + + const merged = buildRightPanelLauncherStates({ + workingTreeFileCount: 0, + diffHasExplicitTarget: false, + pullRequest: { number: 123, state: "merged", isDraft: false }, + ...EMPTY_THREAD, + }); + expect(merged.pullRequest).toEqual({ description: "#123 · Merged", empty: false }); + + const none = buildRightPanelLauncherStates({ + workingTreeFileCount: 0, + diffHasExplicitTarget: false, + ...EMPTY_THREAD, + }); + expect(none.pullRequest).toEqual({ + description: "No pull request on this branch yet.", + empty: true, + }); + }); + it("says the folder is missing instead of describing a tree that is not there", () => { const states = buildRightPanelLauncherStates({ workingTreeFileCount: null, diff --git a/apps/web/src/components/chat/rightPanelLauncherState.ts b/apps/web/src/components/chat/rightPanelLauncherState.ts index 3e5f1d4fb..75f309b49 100644 --- a/apps/web/src/components/chat/rightPanelLauncherState.ts +++ b/apps/web/src/components/chat/rightPanelLauncherState.ts @@ -54,6 +54,13 @@ export interface RightPanelLauncherTurnDiffSummary { readonly files: ReadonlyArray; } +/** Just enough of the thread's resolved pull request to name it on the row. */ +export interface RightPanelLauncherPullRequest { + readonly number: number; + readonly state: "open" | "merged" | "closed"; + readonly isDraft: boolean; +} + /** * How many of the thread's turns actually changed a file. This is what the diff * panel's mode picker lists beneath "Uncommitted changes" and "All chat @@ -166,6 +173,28 @@ function diffState(input: { }; } +/** + * Pull request names the one it found and what state it is in, so the row + * answers "is this branch up for review yet" without opening the surface. A + * branch with nothing on it is dimmed but still opens, onto the empty state + * that points at Source, where the New PR action lives. + */ +function pullRequestState( + pullRequest: RightPanelLauncherPullRequest | null, +): RightPanelSurfaceState { + if (pullRequest === null) { + return { description: "No pull request on this branch yet.", empty: true }; + } + const word = pullRequest.isDraft + ? "Draft" + : pullRequest.state === "merged" + ? "Merged" + : pullRequest.state === "closed" + ? "Closed" + : "Open"; + return { description: `#${pullRequest.number} · ${word}`, empty: false }; +} + /** * Agents reports what the thread has actually run: how many, and how many of * them are still going or asking for something. The counts come from the same @@ -212,6 +241,10 @@ export function buildRightPanelLauncherStates(input: { * tree, so a clean tree says nothing about whether it is empty. */ readonly diffHasExplicitTarget: boolean; readonly agents: RightPanelLauncherAgentsInput | null; + /** The pull request this thread's branch resolved to, or null when it has + * none. Unlike the other rows there is no "not knowable yet" state: an + * unanswered listing reads the same as no pull request. */ + readonly pullRequest?: RightPanelLauncherPullRequest | null; /** The checkout folder itself no longer exists; the tree-backed rows must * not describe a working tree that is not there. */ readonly checkoutMissing?: boolean; @@ -225,6 +258,7 @@ export function buildRightPanelLauncherStates(input: { hasExplicitTarget: input.diffHasExplicitTarget, checkoutMissing, }), + pullRequest: pullRequestState(input.pullRequest ?? null), agents: agentsState(input.agents), }; } @@ -246,8 +280,12 @@ export function useRightPanelLauncherStates(input: { * picker reads. Null means "not knowable here", not "none". */ readonly turnDiffSummaries: ReadonlyArray | null; readonly agents: RightPanelLauncherAgentsInput | null; + /** The thread's resolved pull request, which the route already reads for the + * surface itself. */ + readonly pullRequest?: RightPanelLauncherPullRequest | null; }): RightPanelLauncherStates | undefined { const { agents, enabled } = input; + const pullRequest = input.pullRequest ?? null; const gitStatus = useGitStatus({ environmentId: enabled ? input.environmentId : null, cwd: enabled ? input.cwd : null, @@ -264,6 +302,7 @@ export function useRightPanelLauncherStates(input: { reviewableTurnCount, diffHasExplicitTarget, agents, + pullRequest, checkoutMissing, }) : undefined, @@ -272,6 +311,7 @@ export function useRightPanelLauncherStates(input: { checkoutMissing, diffHasExplicitTarget, enabled, + pullRequest, reviewableTurnCount, workingTreeFileCount, ], diff --git a/apps/web/src/components/diffs/AnnotatedDiffView.tsx b/apps/web/src/components/diffs/AnnotatedDiffView.tsx new file mode 100644 index 000000000..bd22c8d2b --- /dev/null +++ b/apps/web/src/components/diffs/AnnotatedDiffView.tsx @@ -0,0 +1,156 @@ +/** + * A patch rendered as one virtualized list of files, with room for a remark on + * any line. + * + * The Diff panel keeps its per-file `FileDiff` viewer: it opens files in an + * editor and hangs its own chrome off each card. This is the surface for + * review, where a conversation has to sit inside the diff, which per-file + * instances cannot do — an annotation change would remount the file. Both + * wear the same styling, so a diff reads the same wherever it is shown. + */ +import type { + CodeViewDiffItem, + CodeViewItem, + DiffLineAnnotation, + SelectedLineRange, +} from "@pierre/diffs"; +import { CodeView, type CodeViewHandle } from "@pierre/diffs/react"; +import { useCallback, useMemo, type ReactNode, type Ref } from "react"; + +import { useSettings } from "~/hooks/useSettings"; +import { useTheme } from "~/hooks/useTheme"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { computeFileDiffStat } from "../DiffPanel.logic"; +import { DIFF_PANEL_HOST_STYLE, DIFF_PANEL_UNSAFE_CSS } from "../DiffPanel.styles"; +import { FileDiffHeader, resolveFileDiffPath } from "./fileDiffPresentation"; + +/** + * The viewer draws each file's header itself and this one is fully ours, so + * the container only has to reserve the row. It has to match `FileDiffHeader` + * (h-9) plus its hairline, or the virtualizer reserves the wrong height and + * the end of the list sits past the reachable scroll range. + */ +const FILE_HEADER_HEIGHT = 37; + +/** + * On top of the shared diff styling: a hairline over each file so the list + * reads as sections, and an annotation that fills the row rather than sitting + * in the code column's padding. + */ +const ANNOTATED_DIFF_UNSAFE_CSS = ` +[data-diffs-header] { + border-top: 1px solid var(--border) !important; +} + +[data-annotation-content] { + width: 100% !important; + left: 0 !important; +} +`; + +export type AnnotatedDiffViewHandle = CodeViewHandle; + +export function AnnotatedDiffView({ + items, + onToggleCollapsed, + renderAnnotation, + renderFooter, + selectedLines, + onSelectedLinesChange, + onLineSelectionEnd, + enableLineSelection = false, + viewerRef, + className, +}: { + readonly items: readonly CodeViewDiffItem[]; + readonly onToggleCollapsed: (id: string) => void; + readonly renderAnnotation: (annotation: DiffLineAnnotation) => ReactNode; + /** Drawn after the last file, inside the same scroller. Keep the identity stable. */ + readonly renderFooter?: () => ReactNode; + readonly selectedLines?: { readonly id: string; readonly range: SelectedLineRange } | null; + readonly onSelectedLinesChange?: ( + selection: { readonly id: string; readonly range: SelectedLineRange } | null, + ) => void; + /** Called when a drag over the line numbers settles, with the item it landed in. */ + readonly onLineSelectionEnd?: ( + range: SelectedLineRange | null, + context: { readonly item: CodeViewItem }, + ) => void; + readonly enableLineSelection?: boolean; + readonly viewerRef?: Ref>; + readonly className?: string; +}) { + const { resolvedTheme } = useTheme(); + const renderMode = useSettings((settings) => settings.diffRenderMode); + const wordWrap = useSettings((settings) => settings.diffWordWrap); + + // The viewer memoizes each visible file's header and annotation portal on + // these render props and on `options`; a fresh identity on any of them + // rebuilds every portal on screen on any re-render, including a keystroke in + // an open draft. + const renderCustomHeader = useCallback( + (item: CodeViewItem) => { + if (item.type !== "diff") return null; + const filePath = resolveFileDiffPath(item.fileDiff); + const collapsed = item.collapsed === true; + return ( + onToggleCollapsed(item.id)} + stat={computeFileDiffStat(item.fileDiff)} + /> + ); + }, + [onToggleCollapsed], + ); + + const renderItemAnnotation = useCallback( + (annotation: DiffLineAnnotation | { lineNumber: number }) => + "side" in annotation ? renderAnnotation(annotation) : null, + [renderAnnotation], + ); + + const options = useMemo( + () => ({ + diffStyle: renderMode === "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, + enableLineSelection, + enableGutterUtility: enableLineSelection, + unsafeCSS: `${DIFF_PANEL_UNSAFE_CSS}\n${ANNOTATED_DIFF_UNSAFE_CSS}`, + itemMetrics: { diffHeaderHeight: FILE_HEADER_HEIGHT }, + // Two gestures reach the same place: dragging the line numbers marks a + // run, and the gutter's own button marks the one line it sits on. The + // viewer keeps them apart, so a reader who only ever presses the button + // gets nothing unless both are wired. + ...(onLineSelectionEnd + ? { onLineSelectionEnd, onGutterUtilityClick: onLineSelectionEnd } + : {}), + }), + [enableLineSelection, onLineSelectionEnd, renderMode, resolvedTheme, wordWrap], + ); + + return ( + + {...(viewerRef ? { ref: viewerRef } : {})} + items={items} + // The viewer virtualizes against its own scroll box, so this element has + // to be the one that scrolls; otherwise the tab grows to its content and + // nothing scrolls at all. + className={cn("diff-render-surface overflow-auto", className)} + style={DIFF_PANEL_HOST_STYLE} + options={options} + selectedLines={selectedLines ?? null} + {...(onSelectedLinesChange ? { onSelectedLinesChange } : {})} + renderCustomHeader={renderCustomHeader} + renderAnnotation={renderItemAnnotation} + {...(renderFooter ? { renderCodeViewFooter: renderFooter } : {})} + /> + ); +} diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx new file mode 100644 index 000000000..c47709e48 --- /dev/null +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -0,0 +1,144 @@ +/** + * The block a remark occupies inside a diff, on the line it belongs to. + * + * Two shapes, one anatomy: a draft is a box being typed into, a comment is + * words already written. Both carry the range they hang on and whatever + * actions the surface offers. The surface renders the body itself, so this + * knows nothing about where the markdown or the reactions come from. + */ +import type { KeyboardEvent, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; + +/** True for the send gesture every composer in the app uses. */ +export function isCommentSubmitShortcut(event: KeyboardEvent): boolean { + return (event.metaKey || event.ctrlKey) && event.key === "Enter"; +} + +export interface DiffCommentSecondaryAction { + readonly label: string; + readonly onAction: (body: string) => void; +} + +/** + * A remark being written on a line. Escape abandons it, Ctrl or Cmd with Enter + * sends it, and the primary button says where it is going. + */ +export function DiffCommentDraft({ + rangeLabel, + value, + onChange, + onCancel, + onSubmit, + submitLabel, + secondaryAction, + placeholder = "Leave a comment", + pending = false, + className, +}: { + readonly rangeLabel: string; + readonly value: string; + readonly onChange: (value: string) => void; + readonly onCancel: () => void; + readonly onSubmit: (body: string) => void; + readonly submitLabel: string; + readonly secondaryAction?: DiffCommentSecondaryAction; + readonly placeholder?: string; + readonly pending?: boolean; + readonly className?: string; +}) { + const trimmed = value.trim(); + return ( +
event.stopPropagation()} + > +