diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc9..41f8baa119f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -48,6 +48,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDiff]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts new file mode 100644 index 00000000000..8d9df0ffd13 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -0,0 +1,402 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestInvolvement, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestState, +} from "@t3tools/contracts"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + decodePullRequestDetailJson, + decodePullRequestListJson, + decodeRepositoryMergeCapabilitiesJson, + decodeReviewThreadsJson, + PULL_REQUEST_DETAIL_JSON_FIELDS, + PULL_REQUEST_LIST_JSON_FIELDS, + REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS, + REVIEW_THREADS_GRAPHQL_QUERY, + type GitHubPullRequestDetail, + type GitHubPullRequestListItem, + type GitHubReviewThreadComments, +} from "./gitHubPullRequestJson.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitHubPullRequestReadError extends Schema.TaggedErrorClass()( + "GitHubPullRequestReadError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitHub CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: gh answered, the account it answered for just has no login. */ +export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass()( + "GitHubViewerLoginUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitHub CLI returned no login for the authenticated account."; + } + + override get message(): string { + return `GitHub CLI failed in getViewerLogin: ${this.detail}`; + } +} + +export type GitHubPullRequestCliError = + | GitHubCli.GitHubCliError + | GitHubPullRequestReadError + | GitHubViewerLoginUnavailableError; + +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class GitHubPullRequestCli extends Context.Service< + GitHubPullRequestCli, + { + readonly getViewerLogin: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + }) => Effect.Effect; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + GitHubPullRequestCliError + >; + + readonly listReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getRepositoryMergeCapabilities: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly commentOnPullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitHubPullRequestCli") {} + +/** + * `gh --repo` accepts the host-qualified form directly, but the GraphQL API takes owner and + * name as separate arguments and the host as a flag, so the selector is split here. + */ +export function parseRepositorySelector(value: string): { + readonly host: string | null; + readonly owner: string; + readonly name: string; +} { + const parts = value.trim().split("/").filter(Boolean); + const name = parts.at(-1) ?? ""; + const owner = parts.at(-2) ?? ""; + return { host: parts.length > 2 ? (parts.at(-3) ?? null) : null, owner, name }; +} + +function involvementArgs(input: { + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + // `--state closed` includes merged pull requests, so the Closed tab additionally excludes + // them through search; `--author` and `review-requested:` are GitHub's own filters. + const searchTerms = [ + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(input.state === "closed" ? ["is:unmerged"] : []), + ]; + return [ + ...(input.involvement === "authored" ? ["--author", input.viewer] : []), + ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), + ]; +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["merge", `--${mergeMethod ?? "merge"}`]; + case "ready": + return ["ready"]; + case "draft": + return ["ready", "--undo"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + const repositoryArgs = (repository: string) => ["--repo", repository]; + + return GitHubPullRequestCli.of({ + getViewerLogin: (input) => + github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( + Effect.flatMap((result) => { + const login = result.stdout.trim(); + return login.length > 0 + ? Effect.succeed(login) + : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd })); + }), + ), + + listPullRequests: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input.repository), + ...involvementArgs(input), + "--state", + input.state, + "--limit", + // One extra row reveals that the repository has more than the page shows. + String(input.limit + 1), + "--json", + PULL_REQUEST_LIST_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ items: [], truncated: false }); + } + const decoded = decodePullRequestListJson(raw); + return Result.isSuccess(decoded) + ? Effect.succeed({ + items: decoded.success.items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: decoded.success.rawCount > input.limit, + }) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + }), + ), + + getPullRequestDetail: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input.repository), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: decoded.failure, + }), + ); + }), + ), + + getPullRequestDiff: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "diff", + String(input.number), + ...repositoryArgs(input.repository), + "--color", + "never", + "--patch", + ], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.map((result) => ({ + patch: result.stdout, + truncated: result.stdoutTruncated, + })), + ), + + listReviewThreadComments: (input) => { + const { host, owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + ...(host === null ? [] : ["--hostname", host]), + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${input.number}`, + "-f", + `query=${REVIEW_THREADS_GRAPHQL_QUERY}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeReviewThreadsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listReviewThreadComments", + cause: decoded.failure, + }), + ); + }), + ); + }, + + getRepositoryMergeCapabilities: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + input.repository, + "--json", + REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeRepositoryMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getRepositoryMergeCapabilities", + cause: decoded.failure, + }), + ); + }), + ), + + runPullRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + subcommand!, + String(input.number), + ...repositoryArgs(input.repository), + ...flags, + ], + }) + .pipe(Effect.asVoid); + }, + + commentOnPullRequest: (input) => + github + .execute({ + cwd: input.cwd, + // The body travels over stdin: argv is visible in process listings and is echoed + // back inside process-runner failure messages. + args: [ + "pr", + "comment", + String(input.number), + ...repositoryArgs(input.repository), + "--body-file", + "-", + ], + stdin: input.body, + }) + .pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitHubPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts new file mode 100644 index 00000000000..375af2ab39c --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -0,0 +1,107 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities } from "@t3tools/contracts"; + +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +/** `gh pr view --json comments` returns one page; a full page means more exist on GitHub. */ +const CONVERSATION_PAGE_SIZE = 100; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + inlineComments: true, + draft: true, + mergeMethods: ["merge", "squash", "rebase"], +}; + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitHubPullRequestCli.GitHubPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitHubCliUnavailableError") return "missing-tool"; + if (error._tag === "GitHubCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + new PullRequestProviderError({ + provider: "github", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "github", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + }) + .pipe(Effect.mapError(fail("listChangeRequests"))), + + getChangeRequest: (input) => + Effect.all( + [ + cli.getPullRequestDetail(input), + cli.getRepositoryMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + // Line comments live on review threads, which `gh pr view --json` cannot reach. A + // GraphQL hiccup must not blank the whole detail, so it degrades to "none" — marked + // truncated, because an unread thread is a missing comment, not an absent one. + cli + .listReviewThreadComments(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([pullRequest, mergeCapabilities, reviewThreads]): ProviderChangeRequestDetail => ({ + ...pullRequest, + reviewers: pullRequest.reviewRequestLogins.map((login) => ({ login, name: null })), + comments: [...pullRequest.comments, ...reviewThreads.comments].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentsTruncated: + pullRequest.comments.length >= CONVERSATION_PAGE_SIZE || reviewThreads.truncated, + mergeCapabilities, + }), + ), + ), + + getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts new file mode 100644 index 00000000000..9b2ca18d36a --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -0,0 +1,398 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitLabPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function mergeRequests(count: number, firstNumber: number): string { + // @effect-diagnostics-next-line preferSchemaOverJson:off + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + iid: firstNumber + index, + title: `Merge request ${firstNumber + index}`, + web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + })), + ); +} + +/** The endpoint or subcommand of the nth glab invocation. */ +function argsOfCall(index: number): ReadonlyArray { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].args; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitLabPullRequestCli.layer", (it) => { + it.effect("asks GitLab for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("projects/acme%2Fweb/merge_requests"); + expect(path).toContain("per_page=11"); + expect(path).toContain("state=opened"); + }), + ); + + it.effect("walks pages at a fixed size, because GitLab pages by offset", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 150); + assert.isTrue(batch.truncated); + for (const index of [0, 1]) { + expect(argsOfCall(index)[1]).toContain("per_page=100"); + } + expect(argsOfCall(0)[1]).toContain("page=1"); + expect(argsOfCall(1)[1]).toContain("page=2"); + }), + ); + + it.effect("stops walking on a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 40); + assert.isFalse(batch.truncated); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("stops walking when every row on a page fails to decode", () => + Effect.gen(function* () { + // Full pages of unusable rows: nothing is collected, so the collected-count bound never + // trips and only the page bound can end the walk. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); + mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 0); + // ceil((150 + 1) / 100) pages, not one request per page forever. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("filters by the reviewer when the viewer is reviewing", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); + }), + ); + + it.effect("addresses a nested group project by its encoded full path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/platform/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); + }), + ); + + it.effect("merges immediately rather than leaving auto-merge armed", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=false", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("moves a merge request back to draft through glab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "draft", + }); + + expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.commentOnMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + body: "true", + }); + + const call = mockedExecute.mock.calls[0]; + assert.isDefined(call); + expect(call[0].args).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes", + "--method", + "POST", + "--input", + "-", + ]); + // A JSON body, so a comment reading as a literal `true` stays text. + expect(call[0].stdin).toBe('{"body":"true"}'); + }), + ); + + it.effect("walks diff pages and reports files it had to leave behind", () => + Effect.gen(function* () { + const page = (start: number) => + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify( + Array.from({ length: 100 }, (_, index) => ({ + old_path: `src/${start + index}.ts`, + new_path: `src/${start + index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ); + mockedExecute.mockReturnValue(Effect.succeed(output(page(0)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + // Three full pages, then it stops and says the change set was cut short. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + assert.isTrue(diff.truncated); + expect(argsOfCall(2)[1]).toContain("page=3"); + }), + ); + + it.effect("stops walking diffs on a short page and calls the patch complete", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { old_path: "src/a.ts", new_path: "src/a.ts", diff: "@@ -1 +1 @@\n-a\n+b\n" }, + ]), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isFalse(diff.truncated); + expect(diff.patch).toContain("diff --git a/src/a.ts b/src/a.ts"); + }), + ); + + it.effect("returns what it read when the diff response was cut off mid-JSON", () => + Effect.gen(function* () { + const fullPage = (start: number) => + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify( + Array.from({ length: 100 }, (_, index) => ({ + old_path: `src/${start + index}.ts`, + new_path: `src/${start + index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ); + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(fullPage(0)))) + // A byte-truncated prefix: valid JSON never survives the cut. + .mockReturnValueOnce( + Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.isTrue(diff.truncated); + // The first page survives rather than the whole read failing. + expect(diff.patch).toContain("diff --git a/src/0.ts b/src/0.ts"); + }), + ); + + it.effect("offers no squash when the project does not say it allows one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); + }), + ); + + it.effect("reads the project's merge settings as its merge capabilities", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); + }), + ); + + it.effect("fails the read when GitLab returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("fails when the authenticated account has no username", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts new file mode 100644 index 00000000000..dabdd734e89 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -0,0 +1,496 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestState, +} from "@t3tools/contracts"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import { + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeProjectMergeCapabilitiesJson, + decodeViewerJson, + type GitLabMergeRequestDetail, + type GitLabMergeRequestListItem, +} from "./gitLabMergeRequestJson.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitLabMergeRequestReadError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestReadError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitLab CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: glab answered, the account it answered for just has no username. */ +export class GitLabViewerUnavailableError extends Schema.TaggedErrorClass()( + "GitLabViewerUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned no username for the authenticated account."; + } + + override get message(): string { + return `GitLab CLI failed in getViewerUsername: ${this.detail}`; + } +} + +export type GitLabPullRequestCliError = + | GitLabCli.GitLabCliError + | GitLabMergeRequestReadError + | GitLabViewerUnavailableError; + +/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ +const MAX_PAGE_SIZE = 100; +/** Conversation and commit history are read one page deep; the rest stays on GitLab. */ +const CONVERSATION_PAGE_SIZE = 100; +/** Diff pages to walk before a change set is reported as truncated. */ +const DIFF_MAX_PAGES = 3; +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class GitLabPullRequestCli extends Context.Service< + GitLabPullRequestCli, + { + readonly getViewerUsername: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + }) => Effect.Effect; + + readonly getMergeRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listNotes: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly listCommits: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect, GitLabPullRequestCliError>; + + readonly getMergeRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly getProjectMergeCapabilities: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly runMergeRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly commentOnMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitLabPullRequestCli") {} + +/** The REST API addresses a project by its URL-encoded full path. */ +function projectPath(repository: string): string { + return encodeURIComponent(repository.trim()); +} + +function stateParam(state: PullRequestState): string { + // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed. + return state === "open" ? "opened" : state; +} + +function involvementParams(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return [["author_username", input.viewer]]; + case "reviewing": + return [["reviewer_username", input.viewer]]; + case "all": + return []; + } +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return [ + "merge", + // glab turns on auto-merge whenever a pipeline is running. The button means merge now. + "--auto-merge=false", + "--yes", + ...(mergeMethod === "squash" ? ["--squash"] : []), + ...(mergeMethod === "rebase" ? ["--rebase"] : []), + ]; + case "ready": + return ["update", "--ready"]; + case "draft": + return ["update", "--draft"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const api = (input: { + readonly cwd: string; + readonly path: string; + readonly method?: string; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly timeoutMs?: number; + }) => + gitlab.execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A raw body from stdin: argv is visible in process listings and is echoed back + // inside process-runner failure messages. + ...(input.stdin === undefined ? [] : ["--input", "-"]), + ], + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }); + + /** + * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is + * bounded twice over: it stops on a short page or once the extra row that reveals a next + * page has been read, and it never asks for more pages than the caller's page needs. The + * second bound is what makes it terminate when every row on a page fails to decode, which + * leaves nothing collected but does not mean GitLab has run out of rows. + */ + const listPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect => { + // Fixed across the walk: GitLab pages by offset, so a page size that changed between + // requests would skip or repeat rows. One row over the limit probes for a next page. + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const lastPage = Math.ceil((input.limit + 1) / perPage); + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests?${query([ + ["state", stateParam(input.state)], + ...involvementParams(input), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(input.page)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ items: input.collected, truncated: false }); + } + const decoded = decodeMergeRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listMergeRequests", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.items]; + // Counted before decoding, so a skipped malformed row cannot end paging early. + const exhausted = decoded.success.rawCount < perPage; + if (exhausted || collected.length > input.limit || input.page >= lastPage) { + return Effect.succeed({ + items: collected.slice(0, input.limit), + // Anything but a short final page means GitLab may still have rows, including the + // case where enough rows failed to decode to keep the collected count down. + truncated: !exhausted || collected.length > input.limit, + }); + } + return listPage({ ...input, page: input.page + 1, collected }); + }), + ); + }; + + /** + * A merge request's files come one page at a time, so the patch is assembled across pages. + * The walk stops on a short page or at `DIFF_MAX_PAGES`; stopping on a full page means files + * were left behind, which is what `truncated` tells the caller. + */ + const diffPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly sections: ReadonlyArray; + /** GitLab withheld some file's hunks as too large to inline. */ + readonly withheld: boolean; + }): Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/diffs?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ], + )}`, + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => { + const joined = (sections: ReadonlyArray) => + sections.filter((section) => section.length > 0).join("\n"); + // Checked before decoding: a byte-truncated response is a JSON prefix, which would + // fail to parse and lose the pages already read. What was read is worth returning. + if (result.stdoutTruncated) { + return Effect.succeed({ patch: joined(input.sections), truncated: true }); + } + const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: decoded.failure, + }), + ); + } + const sections = [...input.sections, decoded.success.patch]; + const withheld = input.withheld || decoded.success.truncated; + const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; + if (!morePages || input.page >= DIFF_MAX_PAGES) { + return Effect.succeed({ patch: joined(sections), truncated: withheld || morePages }); + } + return diffPage({ ...input, page: input.page + 1, sections, withheld }); + }), + ); + + return GitLabPullRequestCli.of({ + getViewerUsername: (input) => + api({ cwd: input.cwd, path: "user" }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeViewerJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getViewerUsername", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ), + + listMergeRequests: (input) => listPage({ ...input, page: 1, collected: [] }), + + getMergeRequestDetail: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDetail", + cause: decoded.failure, + }), + ); + }), + ), + + listNotes: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( + [ + ["per_page", String(CONVERSATION_PAGE_SIZE)], + ["order_by", "created_at"], + ["sort", "asc"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeNotesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listNotes", + cause: decoded.failure, + }), + ); + } + return Effect.succeed({ + comments: decoded.success.comments, + // The raw count, not the kept count: notes GitLab wrote itself are dropped, so a + // full page of them would otherwise read as "no more notes". + truncated: decoded.success.rawCount >= CONVERSATION_PAGE_SIZE, + }); + }), + ), + + listCommits: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( + [["per_page", String(CONVERSATION_PAGE_SIZE)]], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeCommitsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listCommits", + cause: decoded.failure, + }), + ); + }), + ), + + getMergeRequestDiff: (input) => diffPage({ ...input, page: 1, sections: [], withheld: false }), + + getProjectMergeCapabilities: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}?license=false`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getProjectMergeCapabilities", + cause: decoded.failure, + }), + ); + }), + ), + + runMergeRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return gitlab + .execute({ + cwd: input.cwd, + args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], + }) + .pipe(Effect.asVoid); + }, + + commentOnMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a + // literal `true` or a number, and a comment body is text either way. + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts new file mode 100644 index 00000000000..46ded3d01d6 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -0,0 +1,104 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities } from "@t3tools/contracts"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + inlineComments: true, + draft: true, + // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. + mergeMethods: ["merge", "squash", "rebase"], +}; + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitLabPullRequestCli.GitLabPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitLabCliUnavailableError") return "missing-tool"; + if (error._tag === "GitLabCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => + new PullRequestProviderError({ + provider: "gitlab", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "gitlab", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listMergeRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + }) + .pipe(Effect.mapError(fail("listChangeRequests"))), + + getChangeRequest: (input) => + // GitLab splits a merge request across four endpoints, so they are read together. + Effect.all( + [ + cli.getMergeRequestDetail(input), + cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + // The conversation and the commit list are worth degrading for: neither is reason + // to blank a merge request that was read successfully. An unread conversation counts + // as truncated, so it does not present as one with no comments. + cli + .listNotes(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + ], + { concurrency: 4 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([mergeRequest, mergeCapabilities, notes, commits]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + comments: notes.comments, + commentsTruncated: notes.truncated, + commits, + mergeCapabilities, + }), + ), + ), + + getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + runAction: (input) => + cli + .runMergeRequestAction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts new file mode 100644 index 00000000000..d2e88d2cfee --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -0,0 +1,152 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestCapabilities, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestState, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every provider reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. + * + * `reason` is the part the service acts on: a missing or unauthenticated tool disables the + * provider for the whole workspace, while anything else is specific to the request. + */ +export class PullRequestProviderError extends Schema.TaggedErrorClass()( + "PullRequestProviderError", + { + provider: SourceControlProviderKindSchema, + operation: Schema.String, + reason: Schema.Literals(["missing-tool", "unauthenticated", "failed"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} + +/** A change request as the provider sees it, before the service attaches project context. */ +export interface ProviderChangeRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: { readonly login: string; readonly name: string | null } | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + /** Accounts with a review requested. Team-level requests are excluded by each provider. */ + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface ProviderChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the page size asked for. */ + readonly truncated: boolean; +} + +export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray<{ readonly login: string; readonly name: string | null }>; + readonly checks: ReadonlyArray; + readonly comments: ReadonlyArray; + readonly commentsTruncated: boolean; + readonly commits: ReadonlyArray; + readonly mergeCapabilities: PullRequestMergeCapabilities; +} + +export interface ProviderRepositoryRef { + readonly cwd: string; + /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ + readonly repository: string; +} + +/** + * One host's change requests. Implementations own their own tool and JSON shapes and hand back + * the neutral types above; anything a host cannot do is declared in `capabilities` rather than + * failing at call time. + */ +export interface PullRequestProviderApi { + readonly kind: SourceControlProviderKind; + readonly capabilities: PullRequestCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listChangeRequests: ( + input: ProviderRepositoryRef & { + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + }, + ) => Effect.Effect; + + readonly getChangeRequest: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Only called when `capabilities.diff` is true. */ + readonly getDiff: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + PullRequestProviderError + >; + + readonly runAction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }, + ) => Effect.Effect; + + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, + ) => Effect.Effect; +} + +export class PullRequestProviderRegistry extends Context.Service< + PullRequestProviderRegistry, + { + /** Null for a host with no implementation, which the service reports as unsupported. */ + readonly get: (kind: SourceControlProviderKind) => PullRequestProviderApi | null; + readonly kinds: ReadonlyArray; + } +>()("t3/pullRequest/PullRequestProvider/PullRequestProviderRegistry") {} + +export function makeRegistry( + providers: ReadonlyArray, +): PullRequestProviderRegistry["Service"] { + const byKind = new Map(providers.map((provider) => [provider.kind, provider])); + return { + get: (kind) => byKind.get(kind) ?? null, + kinds: providers.map((provider) => provider.kind), + }; +} diff --git a/apps/server/src/pullRequest/PullRequestProviders.ts b/apps/server/src/pullRequest/PullRequestProviders.ts new file mode 100644 index 00000000000..2bf0116ed75 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProviders.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import { PullRequestProviderRegistry, makeRegistry } from "./PullRequestProvider.ts"; + +/** + * The hosts this build can read change requests from. A host with no entry here still shows up + * in the provider list as unimplemented, so its projects are explained rather than missing. + */ +export const registryLayer = Layer.effect( + PullRequestProviderRegistry, + Effect.map( + Effect.all([GitHubPullRequestProvider.make, GitLabPullRequestProvider.make]), + (providers) => makeRegistry(providers), + ), +).pipe( + Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubCli.layer))), + Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts new file mode 100644 index 00000000000..6a432789856 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -0,0 +1,667 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { + OrchestrationProjectShell, + ProjectId, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + PullRequestProviderError, + PullRequestProviderRegistry, + makeRegistry, + type ProviderChangeRequest, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +function project(input: { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + // The host defaults from the provider, so a fixture only names one when the point of the + // test is two hosts of the same kind. + const host = input.host ?? (input.provider === "gitlab" ? "gitlab.com" : "github.com"); + return { + id: input.id as ProjectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: `${host}/${input.repository}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function changeRequest(number: number, updatedAt: string): ProviderChangeRequest { + return { + number, + title: `Change request ${number}`, + url: `https://host/pull/${number}`, + author: { login: "octocat", name: null }, + headBranch: `feat/${number}`, + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + reviewRequestLogins: [], + labels: [], + }; +} + +function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { + return new PullRequestProviderError({ + provider, + operation: "getViewer", + reason, + detail: `${provider} is not usable.`, + }); +} + +const requestFailed = new PullRequestProviderError({ + provider: "github", + operation: "listChangeRequests", + reason: "failed", + detail: "HTTP 404", +}); + +/** A provider whose every call is supplied by the test; anything unset succeeds emptily. */ +function fakeProvider( + kind: SourceControlProviderKind, + overrides: Partial = {}, +): PullRequestProviderApi { + return { + kind, + capabilities: { diff: true, inlineComments: true, draft: true, mergeMethods: ["merge"] }, + getViewer: () => Effect.succeed("bilal"), + listChangeRequests: () => Effect.succeed({ items: [], truncated: false }), + getChangeRequest: () => Effect.die("unused"), + getDiff: () => Effect.die("unused"), + runAction: () => Effect.void, + comment: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly providers: ReadonlyArray; +}) { + return PullRequestService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(PullRequestProviderRegistry, makeRegistry(input.providers)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: input.projects, + threads: [], + updatedAt: "2026-07-01T00:00:00Z", + }), + }), + ), + ), + ); +} + +it.effect("reads nothing from a host with no implementation, but reports it", () => + Effect.gen(function* () { + const listed: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "notes", workspaceRoot: "/b" }), + project({ + id: "p3", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + listed.push(input.repository); + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(listed, ["pingdotgg/t3code"]); + assert.strictEqual(result.entries[0]?.provider, "github"); + // The GitLab project is explained rather than quietly missing from the page. + assert.deepStrictEqual( + result.providers.map((summary) => ({ + kind: summary.kind, + configured: summary.configured, + projectCount: summary.projectCount, + })), + [ + { kind: "github", configured: true, projectCount: 1 }, + { kind: "gitlab", configured: false, projectCount: 1 }, + ], + ); + }), +); + +it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + // `cli-unauthenticated` would send the reader to `gh auth login` over a transient error. + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("reports an unusable host over a merely failing one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual(error.message.includes("glab"), true); + }), +); + +it.effect("lists every host that has an implementation", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/sub/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + }), + }), + fakeProvider("gitlab", { + listChangeRequests: (input) => + // Nested groups need the full path, not the last two segments. + input.repository === "group/sub/project" + ? Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + }) + : Effect.die("wrong repository identity"), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [entry.provider, entry.number]), + [ + ["gitlab", 2], + ["github", 1], + ], + ); + }), +); + +it.effect("narrows the listing to one host when asked", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", provider: "gitlab" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["gitlab"], + ); + }), +); + +it.effect("keeps one host listed when another is not set up", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + }), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["github"], + ); + assert.deepStrictEqual( + result.providers.map((summary) => [summary.kind, summary.configured]), + [ + ["github", true], + ["gitlab", false], + ], + ); + }), +); + +it.effect("fails as unavailable only when no host can be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => Effect.fail(unusable("github", "missing-tool")), + }), + ], + }); + + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual( + error._tag === "PullRequestUnavailableError" ? error.reason : null, + "cli-missing", + ); + }), +); + +it.effect("reads a repository once when several worktrees share it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "t3code worktree", + workspaceRoot: "/b", + repository: "PingDotGG/T3Code", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + calls += 1; + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(calls, 1); + assert.strictEqual(result.entries.length, 1); + }), +); + +it.effect("keeps healthy repositories when one of them cannot be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => + input.repository === "pingdotgg/broken" + ? Effect.fail(requestFailed) + : Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectTitle), + ["broken"], + ); + }), +); + +it.effect("tries another workspace on the same host for the viewer", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "broken", workspaceRoot: "/broken", repository: "acme/one" }), + project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/healthy" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "missing-tool")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 2); + assert.strictEqual(result.viewers["github.com"], "bilal"); + }), +); + +it.effect("keeps two hosts of one provider kind as two accounts", () => + Effect.gen(function* () { + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + // The same path on a different host: neither the viewer nor the row may be shared. + repository: "acme/web", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // Both repositories survive de-duplication, each with its own account. + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); + }), +); + +it.effect("reports repositories on a host that could not be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/api", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/cloud" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "unauthenticated")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The healthy host still lists, and the unreadable one is named rather than dropped. + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectId), + ["p2"], + ); + }), +); + +it.effect("flags a review request for the viewer but not on their own change request", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, + { + ...changeRequest(2, "2026-07-02T00:00:00Z"), + author: { login: "bilal", name: null }, + reviewRequestLogins: ["bilal"], + }, + ], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.viewerReviewRequested), + [true, false], + ); + }), +); + +it.effect("refuses a repository that does not belong to the requested project", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a diff on a host that cannot produce one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on azure", + workspaceRoot: "/a", + repository: "org/project", + provider: "azure-devops", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: false, + inlineComments: false, + draft: true, + mergeMethods: ["merge"], + }, + getDiff: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("rejects an empty comment before reaching the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })], + }); + + const error = yield* service + .comment({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + body: " ", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 00000000000..92db7c52b10 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,481 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + PullRequestOperationError, + PullRequestUnavailableError, + type OrchestrationProjectShell, + type PullRequestActionInput, + type PullRequestCommentInput, + type PullRequestDetail, + type PullRequestDiffResult, + type PullRequestListEntry, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListResult, + type PullRequestProviderSummary, + type PullRequestRef, + type SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + PullRequestProviderRegistry, + type ProviderChangeRequest, + type PullRequestProviderApi, + type PullRequestProviderError, +} from "./PullRequestProvider.ts"; + +/** + * Rows per repository when the client does not ask for a page size. None of the provider tools + * expose a cursor, so "load more" re-reads a larger page rather than continuing from an offset + * — cheap at the sizes a change request list reaches, and the tools page internally. + */ +const DEFAULT_REPOSITORY_LIST_LIMIT = 50; +const REPOSITORY_CONCURRENCY = 4; + +export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; + +export class PullRequestService extends Context.Service< + PullRequestService, + { + readonly list: ( + input: PullRequestListInput, + ) => Effect.Effect; + readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly diff: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly runAction: (input: PullRequestActionInput) => Effect.Effect; + readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + } +>()("t3/pullRequest/PullRequestService") {} + +/** A project this page can read: its remote is on a host with an implementation. */ +interface SupportedProject { + readonly project: OrchestrationProjectShell; + readonly api: PullRequestProviderApi; + readonly repository: string; + /** The host the repository lives on, which is the account boundary rather than the kind. */ + readonly host: string; +} + +/** + * What the workspace has, split by whether this build can read it. Hosts with no + * implementation are counted rather than dropped, so their projects are explained in the + * provider list instead of quietly missing from the page. + */ +interface WorkspaceProjects { + readonly supported: ReadonlyArray; + readonly unimplemented: ReadonlyMap; +} + +interface RepositoryBatch { + readonly entries: ReadonlyArray; + readonly errors: ReadonlyArray; + readonly truncated: boolean; +} + +/** A host that cannot be read at all, as opposed to one request that failed. */ +function isProviderUnusable(error: PullRequestProviderError): boolean { + return error.reason === "missing-tool" || error.reason === "unauthenticated"; +} + +function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + cause: error, + }); +} + +function toPullRequestError( + operation: string, +): (error: PullRequestProviderError) => PullRequestError { + return (error) => + isProviderUnusable(error) + ? toUnavailableError(error) + : new PullRequestOperationError({ operation, detail: error.detail, cause: error }); +} + +/** + * The host below which the repository is addressed. `canonicalKey` is the normalized remote, + * `host/owner/repo`, so its first segment is the host; the provider kind stands in when there + * is no canonical key to read, which keeps one bucket per kind as before. + */ +function hostOf(project: OrchestrationProjectShell, kind: SourceControlProviderKind): string { + const host = project.repositoryIdentity?.canonicalKey?.split("/")[0]?.trim(); + return host === undefined || host.length === 0 ? kind : host.toLowerCase(); +} + +/** + * The provider-native repository identity. `displayName` is the full path below the host, which + * is what nested GitLab groups and Azure project paths need; owner/name is the two-segment + * fallback for identities recorded before that field existed. + */ +function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { + const identity = project.repositoryIdentity; + if (!identity) return null; + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + +export const make = Effect.gen(function* () { + const registry = yield* PullRequestProviderRegistry; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const listWorkspaceProjects = ( + filter: Pick, + ): Effect.Effect => + projections.getShellSnapshot().pipe( + Effect.mapError( + (error) => + new PullRequestOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause: error, + }), + ), + Effect.map((snapshot) => { + const supported: SupportedProject[] = []; + const unimplemented = new Map(); + const seen = new Set(); + for (const project of snapshot.projects) { + if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + const kind = project.repositoryIdentity?.provider as + | SourceControlProviderKind + | undefined; + const repository = repositoryIdentityOf(project); + if (kind === undefined || repository === null) continue; + if (filter.provider !== undefined && kind !== filter.provider) continue; + // Worktrees of one repository are separate projects; reading the remote once keeps + // the page from repeating every change request per local checkout. The host is part + // of the key, so the same `owner/repo` on two hosts stays two repositories. + const host = hostOf(project, kind); + const key = `${host} ${repository.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + const api = registry.get(kind); + if (api === null) { + unimplemented.set(kind, (unimplemented.get(kind) ?? 0) + 1); + continue; + } + supported.push({ project, api, repository, host }); + } + return { supported, unimplemented }; + }), + ); + + const requireProject = (ref: PullRequestRef): Effect.Effect => + listWorkspaceProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }): Effect.Effect => { + const match = supported[0]; + if (!match) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. + if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + return Effect.fail( + new PullRequestOperationError({ + operation: "resolveRepository", + detail: "The change request does not belong to the selected project.", + }), + ); + } + return Effect.succeed(match); + }), + ); + + /** + * One viewer lookup per host, tried across that host's workspaces so a single broken checkout + * cannot hide every healthy repository on it. Per host and not per provider kind: two GitHub + * hosts are two accounts, and the wrong login would misattribute every review request. + * + * Its failure doubles as the answer to "is this host set up", which is what the provider + * switcher shows. + */ + const resolveViewers = (projects: ReadonlyArray) => + Effect.forEach( + [...new Set(projects.map(({ host }) => host))], + (host) => { + const forHost = projects.filter((project) => project.host === host); + const api = forHost[0]!.api; + return Effect.firstSuccessOf( + forHost.map(({ project }) => api.getViewer({ cwd: project.workspaceRoot })), + ).pipe( + Effect.map((viewer) => ({ + host, + kind: api.kind, + viewer: viewer as string | null, + error: null as PullRequestProviderError | null, + })), + Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + + const toEntry = (input: { + readonly project: SupportedProject; + readonly item: ProviderChangeRequest; + readonly viewer: string; + }): PullRequestListEntry => { + const viewer = input.viewer.toLowerCase(); + return { + provider: input.project.api.kind, + host: input.project.host, + projectId: input.project.project.id, + projectTitle: input.project.project.title, + repository: input.project.repository, + number: input.item.number, + title: input.item.title, + url: input.item.url, + author: input.item.author, + headBranch: input.item.headBranch, + baseBranch: input.item.baseBranch, + state: input.item.state, + isDraft: input.item.isDraft, + mergeability: input.item.mergeability, + additions: input.item.additions, + deletions: input.item.deletions, + createdAt: input.item.createdAt, + updatedAt: input.item.updatedAt, + viewerReviewRequested: + input.item.author?.login.toLowerCase() !== viewer && + input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), + labels: input.item.labels, + }; + }; + + const list: PullRequestService["Service"]["list"] = (input) => + Effect.gen(function* () { + const involvement = input.involvement ?? "all"; + const { supported: projects, unimplemented } = yield* listWorkspaceProjects(input); + const projectCounts = new Map(); + for (const { api } of projects) { + projectCounts.set(api.kind, (projectCounts.get(api.kind) ?? 0) + 1); + } + + const viewerResults = yield* resolveViewers(projects); + const viewers: Record = {}; + for (const result of viewerResults) { + if (result.viewer !== null) viewers[result.host] = result.viewer; + } + + // The switcher filters by provider kind, so several hosts of one kind collapse into one + // summary: configured when any of them could be read, and detail from one that could not. + const providers: ReadonlyArray = [ + ...[...new Set(viewerResults.map((result) => result.kind))].map((kind) => { + const forKind = viewerResults.filter((result) => result.kind === kind); + return { + kind, + projectCount: projectCounts.get(kind) ?? 1, + configured: forKind.some((result) => result.viewer !== null), + detail: forKind.find((result) => result.error !== null)?.error?.detail ?? null, + }; + }), + ...[...unimplemented].map(([kind, projectCount]) => ({ + kind, + projectCount, + configured: false, + detail: "This host cannot be browsed here yet.", + })), + ]; + + const readable = projects.filter(({ host }) => viewers[host] !== undefined); + // A host that could not be read still has projects, and they are absent from the list. + // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. + const unreadable = projects + .filter(({ host }) => viewers[host] === undefined) + .map(({ project, repository }) => ({ + projectId: project.id, + projectTitle: project.title, + message: `${repository} could not be read.`, + })); + if (readable.length === 0) { + // No host this request covers can be read, so it is not a per-project problem. An + // unusable host is preferred as the reported cause because it names the fix; a host + // that merely failed reports as a failed operation rather than as a signed-out CLI, + // which would send the reader to `auth login` over a transient error. + const errors = viewerResults.flatMap((result) => + result.error === null ? [] : [result.error], + ); + const blocking = errors.find(isProviderUnusable) ?? errors[0]; + if (blocking) { + return yield* toPullRequestError("list")(blocking); + } + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: [], + truncated: false, + }; + } + + const batches = yield* Effect.forEach( + readable, + (project): Effect.Effect => { + const viewer = viewers[project.host]!; + return project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + repository: project.repository, + state: input.state, + involvement, + viewer, + limit: input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT, + }) + .pipe( + Effect.map( + (page): RepositoryBatch => ({ + entries: page.items.map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + }), + ), + // One unreachable repository must not blank the page. A host-level failure is + // already reported through `providers`, so it degrades the same way here. + Effect.orElseSucceed( + (): RepositoryBatch => ({ + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + }), + ), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + truncated: batches.some((batch) => batch.truncated), + }; + }); + + const detail: PullRequestService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + number: input.number, + }) + .pipe( + Effect.mapError(toPullRequestError("detail")), + Effect.map( + (changeRequest): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + comments: changeRequest.comments, + commentsTruncated: changeRequest.commentsTruncated, + commits: changeRequest.commits, + mergeCapabilities: changeRequest.mergeCapabilities, + }), + ), + ), + ), + ); + + const diff: PullRequestService["Service"]["diff"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.capabilities.diff + ? project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("diff"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diff", + detail: "This host cannot provide a diff for a change request.", + }), + ), + ), + ); + + const runAction: PullRequestService["Service"]["runAction"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(toPullRequestError("runAction"))), + ), + ); + + const comment: PullRequestService["Service"]["comment"] = (input) => + // The contract keeps the body verbatim because it is markdown, so the "did the user + // actually write something" check lives here. + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: "A comment cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project) => + project.api + .comment({ + cwd: project.project.workspaceRoot, + repository: project.repository, + number: input.number, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("comment"))), + ), + ); + + return PullRequestService.of({ list, detail, diff, runAction, comment }); +}); + +export const layer = Layer.effect(PullRequestService, make); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts new file mode 100644 index 00000000000..ebfa7114121 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -0,0 +1,197 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodePullRequestDetailJson, + decodePullRequestListJson, + decodeRepositoryMergeCapabilitiesJson, + decodeReviewThreadsJson, +} from "./gitHubPullRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("pull request list decoding", () => { + it("treats a merge timestamp as merged even when the state still says closed", () => { + const [entry] = expectSuccess( + decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), + ).items; + expect(entry?.state).toBe("merged"); + }); + + it("normalizes mergeability and defaults unknown values", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), + ), + ); + expect(batch.items.map((entry) => entry.mergeability)).toEqual([ + "conflicting", + "unknown", + "unknown", + ]); + }); + + it("keeps user review requests and drops team ones, which are not logins", () => { + const [entry] = expectSuccess( + decodePullRequestListJson( + listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), + ), + ).items; + expect(entry?.reviewRequestLogins).toEqual(["octocat"]); + }); + + it("skips malformed entries but still counts them, so paging does not stop early", () => { + const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; + const batch = expectSuccess(decodePullRequestListJson(raw)); + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("pull request detail decoding", () => { + const detailJson = JSON.stringify({ + number: 7, + title: "Detail", + url: "https://github.com/pingdotgg/t3code/pull/7", + headRefName: "feat/detail", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + body: "Body", + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, + { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, + ], + comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], + reviews: [ + { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, + { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, + ], + }); + + it("maps check-run status and commit-status state onto one vocabulary", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["build", "pending"], + ["test", "failure"], + ["ci/legacy", "success"], + ]); + }); + + it("merges reviews with comments in time order and keeps a bodyless approval", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + // r2 approved without writing anything, which is still the event worth seeing. + expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); + expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); + }); + + it("drops a review that carries neither a body nor a state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], + }), + ), + ); + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + totalCount = nodes.length, + ): string => + JSON.stringify({ + data: { repository: { pullRequest: { reviewThreads: { totalCount, nodes } } } }, + }); + + it("keeps unresolved threads and carries their file path", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + { + isResolved: true, + path: "apps/web/src/main.tsx", + comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ]), + ), + ); + expect(result.comments).toHaveLength(1); + expect(result.comments[0]).toMatchObject({ + id: "t1", + kind: "review-comment", + path: "apps/server/src/ws.ts", + }); + }); + + it("reports truncation when GitHub has more threads than the page returned", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson( + [ + { + isResolved: false, + comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ], + 80, + ), + ), + ); + expect(result.truncated).toBe(true); + }); +}); + +describe("repository merge capability decoding", () => { + it("reads the three settings gh reports", () => { + expect( + expectSuccess( + decodeRepositoryMergeCapabilitiesJson( + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + }), + ), + ), + ).toEqual({ merge: true, squash: false, rebase: true }); + }); + + it("fails rather than defaulting open when a setting is missing", () => { + const decoded = decodeRepositoryMergeCapabilitiesJson( + JSON.stringify({ mergeCommitAllowed: true }), + ); + expect(Result.isSuccess(decoded)).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts new file mode 100644 index 00000000000..1a98cc0306d --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -0,0 +1,456 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` + * release that adds a conclusion or a review state must not fail the whole payload. + */ +const RawActorSchema = Schema.Struct({ + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewRequestSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawListItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + labels: Schema.optional(Schema.Array(RawLabelSchema)), +}); + +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommitSchema = Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, +}); + +const RawDetailSchema = Schema.Struct({ + ...RawListItemSchema.fields, + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), + comments: Schema.optional(Schema.Array(RawCommentSchema)), + reviews: Schema.optional(Schema.Array(RawReviewSchema)), + commits: Schema.optional(Schema.Array(RawCommitSchema)), +}); + +/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ +const RawReviewThreadsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + reviewThreads: Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + nodes: Schema.Array( + Schema.Struct({ + isResolved: Schema.optional(Schema.Boolean), + path: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.Struct({ nodes: Schema.Array(RawCommentSchema) }), + }), + ), + }), + }), + }), + }), +}); + +/** Requested together, so a response missing any of them fails rather than defaulting open: + * guessing `true` would offer a merge method the repository forbids. */ +const RawMergeCapabilitiesSchema = Schema.Struct({ + mergeCommitAllowed: Schema.Boolean, + squashMergeAllowed: Schema.Boolean, + rebaseMergeAllowed: Schema.Boolean, +}); + +export const PULL_REQUEST_LIST_JSON_FIELDS = + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; + +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup,comments,reviews,commits`; + +/** Root comments of the first page of review threads; deeper replies stay on GitHub. */ +export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 50) { + totalCount + nodes { + isResolved + path + comments(first: 1) { + nodes { id author { login } body createdAt url } + } + } + } + } + } +}`; + +export const REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS = + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed"; + +export interface GitHubPullRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.login); + return login === null ? null : { login, name: trimmed(raw?.name) }; +} + +function toState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + if (trimmed(raw.mergedAt) !== null) return "merged"; + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + return "open"; +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function toLabels( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label.name); + return name === null ? [] : [{ name, color: trimmed(label.color) }]; + }); +} + +/** + * User review requests only. A team request carries a slug, and the viewer check compares + * these against a login, so keeping slugs here would let an unrelated team read as the + * viewer. Team-routed requests need GitHub's review-requested search to resolve. + */ +function toReviewRequestLogins( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((request) => { + const login = trimmed(request.login); + return login === null ? [] : [login]; + }); +} + +function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { + // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` + // that only exists once the run has completed. + const status = raw.status?.trim().toUpperCase(); + if (status !== undefined && status !== "COMPLETED" && status !== "") { + return "pending"; + } + switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + // A completed check asking for manual intervention is blocking, not neutral. + case "ACTION_REQUIRED": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + ]; + }); +} + +function toComments( + raw: Schema.Schema.Type, +): ReadonlyArray { + const issueComments = (raw.comments ?? []).map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + }), + ); + // A review with neither a body nor a state carries nothing to show. One with a state but no + // body is an approval or a change request, which is the whole event and is kept. + const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { + const submittedAt = trimmed(review.submittedAt); + const reviewState = trimmed(review.state); + if (submittedAt === null || ((review.body ?? "").trim().length === 0 && reviewState === null)) { + return []; + } + return [ + { + id: review.id, + kind: "review", + author: toActor(review.author), + body: review.body ?? "", + createdAt: submittedAt, + url: trimmed(review.url), + path: null, + reviewState, + }, + ]; + }); + return [...issueComments, ...reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { + return { + number: raw.number, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeable), + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + return { + ...toListItem(raw), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + comments: toComments(raw), + commits: (raw.commits ?? []).map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate: commit.committedDate, + })), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeMergeCapabilities = decodeJsonResult(RawMergeCapabilitiesSchema); +const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected pull request + * must not blank the whole list. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitHubPullRequestListItem[] = []; + for (const entry of decoded.success) { + const item = decodeListEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + } + } + return Result.succeed({ items, rawCount: decoded.success.length }); +} + +export function decodePullRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export interface GitHubReviewThreadComments { + readonly comments: ReadonlyArray; + readonly truncated: boolean; +} + +/** + * Unresolved review threads only: a resolved thread is finished work, and surfacing it + * again would put stale findings into the "fix findings" prompt. + */ +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads = decoded.success.data.repository.pullRequest.reviewThreads; + const comments = threads.nodes.flatMap((thread): ReadonlyArray => { + const root = thread.comments.nodes[0]; + if (thread.isResolved === true || root === undefined) return []; + return [ + { + id: root.id, + kind: "review-comment", + author: toActor(root.author), + body: root.body ?? "", + createdAt: root.createdAt, + url: trimmed(root.url), + path: trimmed(thread.path), + reviewState: null, + }, + ]; + }); + return Result.succeed({ + comments, + truncated: (threads.totalCount ?? threads.nodes.length) > threads.nodes.length, + }); +} + +export function decodeRepositoryMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeMergeCapabilities(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + merge: decoded.success.mergeCommitAllowed, + squash: decoded.success.squashMergeAllowed, + rebase: decoded.success.rebaseMergeAllowed, + }) + : Result.fail(decoded.failure); +} diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts new file mode 100644 index 00000000000..85ec2af9f99 --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -0,0 +1,390 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeViewerJson, +} from "./gitLabMergeRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function detailJson(entry: Record): string { + return JSON.stringify({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodeMergeRequestListJson", () => { + it("reads a merge request as a change request", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([ + { + iid: 42, + author: { username: "bilal", name: "Bilal" }, + state: "opened", + merge_status: "can_be_merged", + draft: false, + reviewers: [{ username: "julius" }], + labels: ["backend", " "], + }, + ]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + author: { login: "bilal", name: "Bilal" }, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewRequestLogins: ["julius"], + labels: [{ name: "backend", color: null }], + }); + }); + + it("reports no line counts, which GitLab does not expose", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); + + expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); + }); + + it("treats a merged timestamp as merged whatever the state says", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), + ), + ); + + expect(batch.items[0]?.state).toBe("merged"); + }); + + it("keeps a locked merge request open", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); + + expect(batch.items[0]?.state).toBe("open"); + }); + + it("reads the legacy draft flag", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); + + expect(batch.items[0]?.isDraft).toBe(true); + }); + + it("calls a conflicted merge request conflicting even while the merge check is pending", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), + ); + + expect(batch.items[0]?.mergeability).toBe("conflicting"); + }); + + it("leaves an unfinished merge check unknown", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), + ); + + expect(batch.items[0]?.mergeability).toBe("unknown"); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("decodeMergeRequestDetailJson", () => { + it("reads the description, file count and pipeline", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + description: "Ships the page.", + changes_count: "3", + reviewers: [{ username: "julius", name: "Julius" }], + head_pipeline: { + status: "success", + web_url: "https://gitlab.com/acme/web/-/pipelines/9", + source: "merge_request_event", + }, + }), + ), + ); + + expect(detail.body).toBe("Ships the page."); + expect(detail.changedFiles).toBe(3); + expect(detail.reviewers).toEqual([{ login: "julius", name: null }]); + expect(detail.checks).toEqual([ + { + name: "Pipeline", + status: "success", + description: "merge_request_event", + url: "https://gitlab.com/acme/web/-/pipelines/9", + }, + ]); + }); + + it("reads an uncounted change set as its floor", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), + ); + + expect(detail.changedFiles).toBe(1000); + }); + + it("falls back to no file count when GitLab omits one", () => { + const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); + + expect(detail.changedFiles).toBe(0); + }); + + it("maps a pipeline waiting on a person to neutral, not failure", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), + ); + + expect(detail.checks[0]?.status).toBe("neutral"); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); + }); + + it("returns nothing when the account has no username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); + }); +}); + +describe("decodeNotesJson", () => { + it("keeps comments and drops GitLab's own activity notes", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 1, + body: "assigned to @bilal", + system: true, + created_at: "2026-07-01T00:00:00Z", + }, + { + id: 2, + body: "Looks good.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, + ]), + ), + ); + + expect(notes.comments).toHaveLength(1); + expect(notes.comments[0]).toMatchObject({ + id: "2", + kind: "issue-comment", + body: "Looks good.", + }); + // The raw count keeps the dropped notes visible to the caller, which needs them to page. + expect(notes.rawCount).toBe(3); + }); + + it("reads a line note as a review comment on its file", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 7, + type: "DiffNote", + body: "Rename this.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: "src/app.ts", old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); + + it("falls back to the old path for a note on a deleted line", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 8, + type: "DiffNote", + body: "Gone.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: null, old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]?.path).toBe("src/old.ts"); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + }); + + it("falls back to the creation timestamp when there is no commit date", () => { + const commits = expectSuccess( + decodeCommitsJson(JSON.stringify([{ id: "aaa", created_at: "2026-07-01T00:00:00+08:00" }])), + ); + + expect(commits[0]).toMatchObject({ oid: "aaa", committedDate: "2026-07-01T00:00:00+08:00" }); + }); +}); + +describe("decodeMergeRequestDiffsJson", () => { + it("assembles a unified patch GitLab does not return", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/app.ts", + new_path: "src/app.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + }); + + it("points a new file at /dev/null on the left and a deleted file on the right", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/new.ts", + new_path: "src/new.ts", + new_file: true, + b_mode: "100755", + diff: "@@ -0,0 +1 @@\n+hello\n", + }, + { + old_path: "src/gone.ts", + new_path: "src/gone.ts", + deleted_file: true, + diff: "@@ -1 +0,0 @@\n-bye\n", + }, + ]), + ), + ); + + expect(result.patch).toContain("new file mode 100755"); + expect(result.patch).toContain("--- /dev/null"); + expect(result.patch).toContain("deleted file mode 100644"); + expect(result.patch).toContain("+++ /dev/null"); + }); + + it("records a rename so the patch names both paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.patch).toContain("rename to src/new.ts"); + }); + + it("reports truncation for a file GitLab refused to inline", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), + ), + ); + + expect(result.truncated).toBe(true); + expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); + }); + + it("reports how many files GitLab returned, so the caller can page", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify( + Array.from({ length: 3 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ), + ), + ); + + expect(result.rawCount).toBe(3); + expect(result.truncated).toBe(false); + expect(result.patch).toContain("src/2.ts"); + }); + + it("fails when GitLab did not return a list", () => { + expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts new file mode 100644 index 00000000000..d51a1e5a73b --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -0,0 +1,470 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeability, + PullRequestMergeCapabilities, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that + * adds a pipeline status or a merge status must not fail the whole payload. + */ +const RawUserSchema = Schema.Struct({ + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPipelineSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + web_url: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source_branch: Schema.String, + target_branch: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + merge_status: Schema.optional(Schema.NullOr(Schema.String)), + has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), + created_at: Schema.String, + updated_at: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. + changes_count: Schema.optional(Schema.NullOr(Schema.String)), + head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ + system: Schema.optional(Schema.Boolean), + type: Schema.optional(Schema.NullOr(Schema.String)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawCommitSchema = Schema.Struct({ + id: Schema.String, + title: Schema.optional(Schema.NullOr(Schema.String)), + committed_date: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawDiffSchema = Schema.Struct({ + old_path: Schema.String, + new_path: Schema.String, + a_mode: Schema.optional(Schema.NullOr(Schema.String)), + b_mode: Schema.optional(Schema.NullOr(Schema.String)), + new_file: Schema.optional(Schema.Boolean), + renamed_file: Schema.optional(Schema.Boolean), + deleted_file: Schema.optional(Schema.Boolean), + diff: Schema.optional(Schema.NullOr(Schema.String)), + /** GitLab omits the hunks for a file it considers too large to inline. */ + too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** And for one it collapsed, which withholds them the same way. */ + collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const RawViewerSchema = Schema.Struct({ + username: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A GitLab project settles on one merge strategy plus an optional squash. */ +const RawProjectMergeSettingsSchema = Schema.Struct({ + merge_method: Schema.optional(Schema.NullOr(Schema.String)), + squash_option: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export interface GitLabMergeRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** + * GitLab reports neither added nor removed lines on a merge request, so both stay zero and + * the surface omits the stat. The Code tab counts them from the patch it already fetched. + */ + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.username); + return login === null ? null : { login, name: trimmed(raw?.name) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + if (trimmed(raw.merged_at) !== null) return "merged"; + switch (raw.state?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +function toMergeability( + raw: Schema.Schema.Type, +): PullRequestMergeability { + if (raw.has_conflicts === true) return "conflicting"; + switch (raw.merge_status?.trim().toLowerCase()) { + case "can_be_merged": + return "mergeable"; + case "cannot_be_merged": + return "conflicting"; + default: + // `unchecked` and `checking` mean GitLab has not finished the merge check yet. + return "unknown"; + } +} + +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + // GitLab returns label names only, so there is no colour to carry. + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +/** + * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is + * the floor either way, which reads better than dropping an uncounted change set to nothing. + */ +function toChangedFiles(value: string | null | undefined): number { + const parsed = Number.parseInt(value?.trim() ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toLowerCase()) { + case "success": + return "success"; + case "failed": + return "failure"; + case "canceled": + case "cancelling": + return "cancelled"; + case "skipped": + return "skipped"; + // A pipeline waiting on a person is not progress, and it is not a failure either. + case "manual": + case "scheduled": + return "neutral"; + default: + return "pending"; + } +} + +/** + * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one + * check. The jobs behind it stay one click away through the pipeline URL. + */ +function toChecks( + raw: Schema.Schema.Type, +): ReadonlyArray { + const pipeline = raw.head_pipeline; + if (!pipeline) return []; + return [ + { + name: "Pipeline", + status: toPipelineStatus(pipeline.status), + description: trimmed(pipeline.source), + url: trimmed(pipeline.web_url), + }, + ]; +} + +function toListItem( + raw: Schema.Schema.Type, +): GitLabMergeRequestListItem { + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + headBranch: raw.source_branch, + baseBranch: raw.target_branch, + state: toState(raw), + isDraft: raw.draft ?? raw.work_in_progress ?? false, + mergeability: toMergeability(raw), + additions: 0, + deletions: 0, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null ? [] : [login]; + }), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { + const listItem = toListItem(raw); + return { + ...listItem, + body: raw.description ?? "", + changedFiles: toChangedFiles(raw.changes_count), + mergedAt: trimmed(raw.merged_at), + closedAt: trimmed(raw.closed_at), + reviewers: listItem.reviewRequestLogins.map((login) => ({ login, name: null })), + checks: toChecks(raw), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); +const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected merge request + * must not blank the whole list. */ +export function decodeMergeRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitLabMergeRequestListItem[] = []; + for (const entry of decoded.success) { + const item = decodeMergeRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + } + } + return Result.succeed({ items, rawCount: decoded.success.length }); +} + +export function decodeMergeRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeMergeRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.username)) + : Result.fail(decoded.failure); +} + +/** + * GitLab settles the strategy per project rather than offering all three per merge request: + * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a + * separate switch. An unrecognized setting offers nothing rather than offering a strategy the + * project forbids. + */ +export function decodeProjectMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeProjectMergeSettings(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); + const squashOption = decoded.success.squash_option?.trim().toLowerCase(); + return Result.succeed({ + merge: mergeMethod === "merge", + // Both semi-linear and fast-forward histories are reached by rebasing onto the target. + rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", + // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, + // rather than offering a squash the project may forbid. + squash: + squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", + }); +} + +/** + * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the + * root of a line-level discussion, which is what the review-comment kind means. + * + * The raw note count comes back alongside, because dropping notes hides whether the page was + * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. + */ +export function decodeNotesJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly rawCount: number }, + DecodeFailure +> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + if (value.system === true) continue; + const body = value.body ?? ""; + if (body.trim().length === 0) continue; + const isDiffNote = value.type?.trim() === "DiffNote"; + comments.push({ + id: String(value.id), + kind: isDiffNote ? "review-comment" : "issue-comment", + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), + reviewState: null, + }); + } + return Result.succeed({ comments, rawCount: decoded.success.length }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success) { + const commit = decodeCommitEntry(entry); + if (Exit.isFailure(commit)) continue; + const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); + if (committedDate === null) continue; + commits.push({ + oid: commit.value.id, + messageHeadline: commit.value.title ?? "", + committedDate, + }); + } + // GitLab lists a merge request's commits newest first; the timeline reads oldest first. + return Result.succeed(commits.toReversed()); +} + +function diffHeaderPaths(raw: Schema.Schema.Type): { + readonly from: string; + readonly to: string; +} { + return { + from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, + to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, + }; +} + +export interface GitLabMergeRequestPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitLab as too large to inline. */ + readonly truncated: boolean; + /** Files GitLab returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; +} + +/** + * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff + * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, + * which is why the raw file count comes back with the patch. + */ +export function decodeMergeRequestDiffsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeDiffEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.diff ?? ""; + if (hunks.length === 0) { + // A file GitLab declined to inline still belongs in the file list, header only. + truncated = truncated || value.too_large === true || value.collapsed === true; + } + const { from, to } = diffHeaderPaths(value); + const header = [ + `diff --git a/${value.old_path} b/${value.new_path}`, + ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), + ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), + ...(value.renamed_file === true + ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] + : []), + `--- ${from}`, + `+++ ${to}`, + ].join("\n"); + sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join("\n"), + truncated, + rawCount: decoded.success.length, + }); +} diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..a705b0fb0b3 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -203,6 +203,9 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -314,6 +317,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0ef..05475400d42 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -249,6 +249,9 @@ export class GitLabCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listMergeRequests: (input: { @@ -401,6 +404,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe(Effect.mapError(mapError)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af80..6384ed20225 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -101,6 +101,8 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as PullRequestProviders from "./pullRequest/PullRequestProviders.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -395,6 +397,7 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const pullRequests = yield* PullRequestService.PullRequestService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1510,6 +1513,26 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.pullRequestsList]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDetail]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDiff]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDiff, pullRequests.diff(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsRunAction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -2051,6 +2074,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), + Layer.provide( + PullRequestService.layer.pipe( + // One registry entry per supported host; the service only knows the registry. + Layer.provide(PullRequestProviders.registryLayer), + Layer.provide(VcsProcess.layer), + ), + ), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx new file mode 100644 index 00000000000..8a9e791d8b3 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -0,0 +1,176 @@ +import { CodeView, type CodeViewDiffItem } from "@pierre/diffs/react"; +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { + buildFileDiffRenderKey, + getDiffLineStat, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { Skeleton } from "../ui/skeleton"; +import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; + +/** + * The pull request's patch, rendered with the same viewer as the thread diff panel. It renders + * the remote patch only — review comments belong to a thread's composer, which this page has + * no equivalent of, so the annotatable wrapper is deliberately not used here. + */ +export function PullRequestCodeTab({ + environmentId, + reference, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; +}) { + const { resolvedTheme } = useTheme(); + const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); + const diffQuery = useEnvironmentQuery( + pullRequestEnvironment.diff({ environmentId, input: reference }), + ); + + const renderablePatch = useMemo( + () => + getRenderablePatch( + diffQuery.data?.patch, + `pull-request:${reference.repository}#${reference.number}:${resolvedTheme}`, + ), + [diffQuery.data?.patch, reference.number, reference.repository, resolvedTheme], + ); + const files = useMemo( + () => + renderablePatch?.kind === "files" + ? renderablePatch.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right)), + ) + : [], + [renderablePatch], + ); + const items = useMemo( + () => + files.map((fileDiff) => { + const fileKey = buildFileDiffRenderKey(fileDiff); + const collapsed = collapsedFiles.has(fileKey); + return { + id: fileKey, + type: "diff", + fileDiff, + collapsed, + // The viewer re-renders an item only when its version changes, and collapsing is + // the only thing that varies for a patch this page never edits. + version: collapsed ? 1 : 0, + }; + }), + [collapsedFiles, files], + ); + const lineStat = useMemo(() => getDiffLineStat(files), [files]); + + const toggleFile = (fileKey: string) => + setCollapsedFiles((current) => { + const next = new Set(current); + if (next.has(fileKey)) next.delete(fileKey); + else next.add(fileKey); + return next; + }); + + if (diffQuery.isPending && !diffQuery.data) { + return ( +
+ + +
+ ); + } + + if (diffQuery.error) { + return

{diffQuery.error}

; + } + + // A patch the viewer cannot structure (binary, or a format it does not parse) still has to + // be readable, so it falls back to the raw text rather than an empty tab. + if (renderablePatch?.kind === "raw") { + return ( +
+

{renderablePatch.reason}

+
+          {renderablePatch.text}
+        
+
+ ); + } + + if (items.length === 0) { + return ( +

This pull request has no file changes.

+ ); + } + + return ( + +
+ + + {files.length} {files.length === 1 ? "file" : "files"} + + + {diffQuery.data?.truncated ? diff truncated : null} + + {/* The viewer renders at its natural height, so its host element is what scrolls — + the same contract the thread diff panel uses. */} +
+ { + const collapsed = collapsedFiles.has(item.id); + return ( + + ); + }} + /> +
+
+
+ ); +} + +export default PullRequestCodeTab; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx new file mode 100644 index 00000000000..c0a9625d6af --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -0,0 +1,428 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { + EnvironmentId, + PullRequestAction, + PullRequestMergeMethod, + PullRequestRef, +} from "@t3tools/contracts"; +import { + ExternalLinkIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + HammerIcon, + LinkIcon, + MoreHorizontalIcon, + XIcon, +} from "lucide-react"; +import { lazy, Suspense, useState } from "react"; + +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { useEnvironmentQuery } from "~/state/query"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { Skeleton } from "../ui/skeleton"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; +import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; +import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; +import { buildFixFindingsPrompt, buildResolveConflictsPrompt } from "./pullRequestDetail.logic"; +import { PullRequestStateGlyph } from "./pullRequestPresentation"; + +type DetailTab = "summary" | "timeline" | "code"; + +const ACTION_SUCCESS_LABELS: Record = { + merge: "Pull request merged", + ready: "Marked ready for review", + draft: "Converted to draft", + close: "Pull request closed", + reopen: "Pull request reopened", +}; + +const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ + { value: "summary", label: "Summary" }, + { value: "timeline", label: "Timeline" }, + { value: "code", label: "Code" }, +]; + +// The diff viewer pulls in its worker pool, so it stays out of the bundle until Code is opened. +const PullRequestCodeTab = lazy(() => import("./PullRequestCodeTab")); + +export function PullRequestDetailPanel({ + environmentId, + reference, + onClose, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + onClose: () => void; +}) { + const [tab, setTab] = useState("summary"); + const [mergeMethod, setMergeMethod] = useState("merge"); + const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null); + const [handoff, setHandoff] = useState<"findings" | "conflicts" | null>(null); + + const detailQuery = useEnvironmentQuery( + pullRequestEnvironment.detail({ environmentId, input: reference }), + ); + const detail = detailQuery.data; + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const [actionPending, setActionPending] = useState(false); + const newThread = useNewThreadHandler(); + const prepareThread = usePreparePullRequestThreadAction({ + environmentId, + cwd: detail?.workspaceRoot ?? null, + }); + + const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { + if (actionPending) return; + setActionPending(true); + const result = await runAction({ + environmentId, + input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, + }); + setActionPending(false); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Pull request action failed" }); + return; + } + toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); + detailQuery.refresh(); + }; + + // Both handoffs work the same way: check the pull request out into its own worktree, open a + // thread there, and hand the user the task-specific prompt to review before sending. + const startHandoff = async (kind: "findings" | "conflicts", prompt: string) => { + if (!detail || handoff !== null) return; + setHandoff(kind); + const prepared = await prepareThread.run({ reference: detail.url, mode: "worktree" }); + if (prepared._tag === "Failure") { + setHandoff(null); + toastManager.add({ type: "error", title: "Could not prepare the pull request checkout" }); + return; + } + await newThread(scopeProjectRef(environmentId, detail.projectId), { + branch: prepared.value.branch, + worktreePath: prepared.value.worktreePath, + envMode: "worktree", + }); + // The checkout and the thread already exist by this point, so a denied clipboard is + // reported on its own rather than being mistaken for the handoff having failed. + const copied = await writeTextToClipboard(prompt).then( + () => true, + () => false, + ); + setHandoff(null); + toastManager.add( + copied + ? { + type: "success", + title: "Checkout ready", + description: "The prompt is on your clipboard — paste it to start.", + } + : { + type: "error", + title: "Checkout ready, but the prompt could not be copied", + description: "Copy the task into the composer manually.", + }, + ); + }; + + // The host says which strategies it offers at all; the repository narrows that to the ones + // it actually allows. + const allowedMergeMethods = detail + ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) + : []; + const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) + ? mergeMethod + : (allowedMergeMethods[0] ?? "merge"); + const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; + + return ( +
+
+ {detail ? ( + + ) : null} + +
+ {detail ? ( + <> + + void readLocalApi()?.shell.openExternal(detail.url)} + /> + } + > + + + Open on GitHub + + + + + + + {detail.state === "open" ? ( + <> + void perform(detail.isDraft ? "ready" : "draft")} + > + {detail.isDraft ? ( + + ) : ( + + )} + {detail.isDraft ? "Ready for review" : "Convert to draft"} + + {/* A preference for the merge action rather than a second action, so it + is a radio group here instead of a chevron welded to the Merge pill. + Hidden while conflicting: every method would fail. */} + {!detail.isDraft && !conflicting && allowedMergeMethods.length > 1 ? ( + <> + + + setMergeMethod(method as PullRequestMergeMethod) + } + > + {allowedMergeMethods.map((method) => ( + + + {method} + + ))} + + + ) : null} + + + ) : null} + void writeTextToClipboard(detail.url)}> + + Copy link + + + void startHandoff( + "findings", + buildFixFindingsPrompt({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + comments: detail.comments, + checks: detail.checks, + commentsTruncated: detail.commentsTruncated, + }), + ) + } + > + + {handoff === "findings" ? "Preparing..." : "Fix findings in a thread"} + + {conflicting ? ( + + void startHandoff( + "conflicts", + buildResolveConflictsPrompt({ + number: detail.number, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + }), + ) + } + > + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts in a thread"} + + ) : null} + {detail.state === "open" ? ( + <> + + setConfirmAction("close")} + > + + Close pull request + + + ) : detail.state === "closed" ? ( + <> + + void perform("reopen")}> + + Reopen pull request + + + ) : null} + + + {detail.state === "open" && detail.isDraft ? ( + + ) : detail.state === "open" && conflicting ? ( + + + } + > + Merge + + Resolve merge conflicts before merging + + ) : detail.state === "open" && allowedMergeMethods.length > 0 ? ( + + ) : null} + + ) : null} + +
+
+ +
+ {detailQuery.isPending && !detail ? ( +
+ + + +
+ ) : detailQuery.error && !detail ? ( + detailQuery.refresh()} + /> + ) : detail ? ( + tab === "summary" ? ( + detailQuery.refresh()} + /> + ) : tab === "timeline" ? ( + + ) : ( + }> + + + ) + ) : null} +
+ + !open && setConfirmAction(null)} + > + + + + {confirmAction === "merge" ? "Merge pull request?" : "Close pull request?"} + + + {confirmAction === "merge" + ? `This merges #${reference.number} using ${selectedMergeMethod}.` + : `This closes #${reference.number} without merging it.`} + + + + }> + Cancel + + + + + +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx new file mode 100644 index 00000000000..96f4b0c0fc0 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -0,0 +1,189 @@ +import type { + ProjectId, + PullRequestProviderSummary, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { ListFilterIcon, SearchIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; + +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; + +/** + * Plain text pills with a chip behind the active option. Two groups sit on one row — + * involvement then state — so the row reads as "which pull requests, in which state". + */ +export function PullRequestFilterPills({ + value, + options, + onChange, +}: { + value: Value; + options: ReadonlyArray<{ readonly value: Value; readonly label: string }>; + onChange: (value: Value) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +/** + * Host switcher, in the same pill chrome as the filters beside it. It only renders once a + * workspace actually spans more than one host: with a single host there is nothing to switch + * between, and an always-visible control would only raise the question. It also renders while + * a host is selected whatever the list says, so a link that arrives already filtered still + * offers the way back out. + * + * A host that cannot be read stays in the row, disabled, carrying the server's reason as its + * title — the projects on it are missing from the list either way, and a dimmed pill explains + * that where an absent one would not. + */ +export function PullRequestProviderFilter({ + providers, + value, + onChange, +}: { + providers: ReadonlyArray; + value: SourceControlProviderKind | undefined; + onChange: (provider: SourceControlProviderKind | undefined) => void; +}) { + if (providers.length < 2 && value === undefined) { + return null; + } + return ( +
+ + {providers.map((provider) => { + const { Icon, providerName } = getSourceControlPresentationForKind(provider.kind); + const active = provider.kind === value; + return ( + + ); + })} +
+ ); +} + +export function PullRequestSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ + onChange(event.currentTarget.value)} + placeholder="Search pull requests" + aria-label="Search pull requests" + className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" + /> +
+ ); +} + +/** + * Project scope lives behind the filter icon so the row stays two controls wide. It is the + * same menu chrome as the detail panel's actions, which also owns its own spacing. + */ +const ALL_PROJECTS_VALUE = "all"; + +export function PullRequestProjectFilter({ + projects, + value, + onChange, +}: { + projects: ReadonlyArray<{ readonly id: ProjectId; readonly title: string }>; + value: ProjectId | undefined; + onChange: (projectId: ProjectId | undefined) => void; +}) { + const selectedTitle = projects.find((project) => project.id === value)?.title; + return ( + + + + {value !== undefined ? ( + + ) : null} + + + + onChange(next === ALL_PROJECTS_VALUE ? undefined : (next as ProjectId)) + } + > + All projects + {projects.map((project) => ( + + {project.title} + + ))} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx new file mode 100644 index 00000000000..d1d48be0710 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -0,0 +1,42 @@ +import { cn } from "~/lib/utils"; + +import ChatMarkdown from "../ChatMarkdown"; +import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +/** + * A pull request body, rendered with the app's markdown renderer plus inline players for the + * videos GitHub embeds — the renderer has no element for those, so they would otherwise show + * up as a bare link. + */ +export function PullRequestMarkdown({ + text, + cwd, + className, +}: { + text: string; + cwd: string; + className?: string; +}) { + const segments = splitPullRequestBody(text); + return ( +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx new file mode 100644 index 00000000000..5c738516fb3 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -0,0 +1,63 @@ +import type { PullRequestListEntry } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { + PullRequestDiffStat, + PullRequestMetaLine, + PullRequestStateGlyph, +} from "./pullRequestPresentation"; + +export function PullRequestRow({ + entry, + selected, + showProjectTitle, + showProvider, + onSelect, +}: { + entry: PullRequestListEntry; + selected: boolean; + showProjectTitle: boolean; + /** Only when the list spans more than one host, where the repository alone is ambiguous. */ + showProvider: boolean; + onSelect: (entry: PullRequestListEntry) => void; +}) { + const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx new file mode 100644 index 00000000000..eb3ee5f6069 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -0,0 +1,290 @@ +import type { EnvironmentId, PullRequestDetail } from "@t3tools/contracts"; +import { + ChevronRightIcon, + CircleDotIcon, + GitBranchIcon, + GitMergeIcon, + MessageSquareIcon, + SendIcon, + UsersIcon, +} from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { useAtomCommand } from "~/state/use-atom-command"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Textarea } from "../ui/textarea"; +import { toastManager } from "../ui/toast"; +import { + PullRequestActorLabel, + PullRequestCheckStatusIcon, + PullRequestDiffStat, + PullRequestMetaLine, + pullRequestCheckStatusLabel, + summarizePullRequestChecks, +} from "./pullRequestPresentation"; +import { describePullRequestState } from "./pullRequestDetail.logic"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; + +function MetaRow({ + icon, + label, + children, +}: { + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+ + {icon} + {label} + + {children} +
+ ); +} + +function Section({ + title, + count, + defaultOpen = true, + children, +}: { + title: string; + count?: number; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( + + {/* Title first, chevron riding to its right, count last: the row reads as a heading + with an affordance rather than a tree node. */} + + {title} + + {count === undefined ? null : ( + {count} + )} + + +
{children}
+
+
+ ); +} + +function CommentComposer({ + environmentId, + detail, + onCommented, +}: { + environmentId: EnvironmentId; + detail: PullRequestDetail; + onCommented: () => void; +}) { + const [body, setBody] = useState(""); + const [posting, setPosting] = useState(false); + const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); + + const submit = async () => { + const trimmed = body.trim(); + if (trimmed.length === 0 || posting) return; + setPosting(true); + const result = await postComment({ + environmentId, + input: { + projectId: detail.projectId, + repository: detail.repository, + number: detail.number, + body: trimmed, + }, + }); + setPosting(false); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not post the comment" }); + return; + } + setBody(""); + onCommented(); + }; + + return ( +
+