diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 4faaba2c66cf..f51cc4c3997a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -91,6 +91,7 @@ import { type GitHubReviewThreadPage, type GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; +import { makeGitHubNativeStackRead } from "./gitHubNativeStack.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; /** @@ -407,6 +408,8 @@ export interface GitHubPullRequestDiffSlice { export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCli, { + readonly getNativeDependencyMembership: ReturnType; + readonly getViewerLogin: (input: { readonly cwd: string; }) => Effect.Effect; @@ -1460,6 +1463,7 @@ export const make = Effect.gen(function* () { ); return GitHubPullRequestCli.of({ + getNativeDependencyMembership: makeGitHubNativeStackRead(github.execute), getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( Effect.flatMap((result) => { diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index b700e554b4ce..7c18658116b1 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -210,6 +210,8 @@ export const make = Effect.gen(function* () { kind: "github", capabilities: CAPABILITIES, + getNativeDependencyMembership: (input) => cli.getNativeDependencyMembership(input), + getViewer: (input) => cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), diff --git a/apps/server/src/pullRequest/gitHubNativeStack.test.ts b/apps/server/src/pullRequest/gitHubNativeStack.test.ts new file mode 100644 index 000000000000..feb95f2fdc43 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubNativeStack.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { makeGitHubNativeStackRead } from "./gitHubNativeStack.ts"; + +const input = { cwd: "/w", repository: "acme/web", host: "github.com", number: 2, limit: 100 }; +const repo = { url: "https://api.github.com/repos/acme/web" }; +const member = (number: number) => ({ + number, + title: `Change ${number}`, + html_url: `https://github.com/acme/web/pull/${number}`, + state: "open", + draft: false, + merged_at: null as string | null, + head: { ref: `branch-${number}`, repo }, + base: { ref: number === 1 ? "main" : `branch-${number - 1}`, repo }, +}); +const stack = (members = [member(1), member(2)]) => ({ + number: 6, + node_id: "PRS_6", + pull_requests: members, +}); +function fixture(responses: ReadonlyArray, truncated = false) { + const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const encoded = responses.map((response) => encode(response)); + const requests: ReadonlyArray[] = []; + const read = makeGitHubNativeStackRead((request) => + Effect.sync(() => { + requests.push(request.args); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: encoded[requests.length - 1] ?? "", + stderr: "", + stdoutTruncated: truncated, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }), + ); + return { read, requests }; +} + +it.effect( + "reads an exact membership and preserves merged roots with qualified source identity", + () => + Effect.gen(function* () { + const root = { ...member(1), state: "closed", merged_at: "2026-09-01T00:00:00Z" }; + const { read, requests } = fixture([[{ number: 6 }], stack([root, member(2)])]); + const result = yield* read(input); + expect(requests).toHaveLength(2); + expect(requests[0]).toContain("repos/acme/web/stacks?pull_request=2&per_page=2&page=1"); + expect(result.status).toBe("present"); + if (result.status !== "present") return; + expect( + result.members.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]), + ).toEqual([ + [1, "merged", "acme/web"], + [2, "open", "acme/web"], + ]); + expect(result.coverage).toBe("complete"); + }), +); + +it.effect("only a successful empty exact lookup establishes no native stack", () => + Effect.gen(function* () { + const { read, requests } = fixture([[]]); + expect(yield* read(input)).toEqual({ status: "none" }); + expect(requests).toHaveLength(1); + const broken = fixture([[{ number: 6 }]], true); + expect(Result.isFailure(yield* Effect.result(broken.read(input)))).toBe(true); + }), +); + +it.effect("bounds native membership while retaining the selected member", () => + Effect.gen(function* () { + const { read } = fixture([[{ number: 6 }], stack([member(1), member(2), member(3)])]); + const result = yield* read({ ...input, number: 3, limit: 2 }); + expect(result.status).toBe("present"); + if (result.status !== "present") return; + expect(result.members.map((pr) => pr.number)).toEqual([2, 3]); + expect(result.coverage).toBe("partial"); + }), +); + +it.effect( + "rejects mismatched repositories, duplicate members, missing focus and ambiguous membership", + () => + Effect.gen(function* () { + const foreign = { + ...member(2), + base: { ref: "main", repo: { url: "https://api.github.com/repos/other/web" } }, + }; + for (const responses of [ + [[{ number: 6 }], stack([member(1), foreign])], + [[{ number: 6 }], stack([member(2), member(2)])], + [[{ number: 6 }], stack([member(1)])], + [[{ number: 6 }], stack([{ ...member(2), title: " " }])], + [[{ number: 6 }], { ...stack(), node_id: "" }], + [[{ number: 6 }, { number: 7 }]], + ]) { + const { read } = fixture(responses); + expect(Result.isFailure(yield* Effect.result(read(input)))).toBe(true); + } + }), +); + +it.effect("does not infer source identity from membership or a different API host", () => + Effect.gen(function* () { + const fork = { + ...member(1), + head: { ref: "branch-1", repo: { url: "https://api.github.com/repos/fork/web" } }, + }; + const unknown = { + ...member(2), + head: { ref: "branch-2", repo: { url: "https://other.example/repos/acme/web" } }, + }; + const { read } = fixture([[{ number: 6 }], stack([fork, unknown])]); + const result = yield* read(input); + if (result.status !== "present") return yield* Effect.die("expected native members"); + expect(result.members.map((pr) => pr.headRepositoryNameWithOwner)).toEqual(["fork/web", null]); + }), +); + +it.effect("leaves unverified enterprise hosts unavailable without an API request", () => + Effect.gen(function* () { + const { read, requests } = fixture([]); + expect(Result.isFailure(yield* Effect.result(read({ ...input, host: "git.acme.test" })))).toBe( + true, + ); + expect(requests).toHaveLength(0); + }), +); diff --git a/apps/server/src/pullRequest/gitHubNativeStack.ts b/apps/server/src/pullRequest/gitHubNativeStack.ts new file mode 100644 index 000000000000..3983b63ba4ee --- /dev/null +++ b/apps/server/src/pullRequest/gitHubNativeStack.ts @@ -0,0 +1,153 @@ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import type { GitHubCli } from "../sourceControl/GitHubCli.ts"; +import { + PullRequestProviderError, + type ProviderNativeDependencyMembership, + type ProviderRepositoryRef, +} from "./PullRequestProvider.ts"; + +const StackNumber = Schema.Int.check(Schema.isGreaterThan(0)); +const decodeList = decodeJsonResult(Schema.Array(Schema.Struct({ number: StackNumber }))); +const Repository = Schema.Struct({ url: Schema.String }); +const Branch = Schema.Struct({ ref: TrimmedNonEmptyString, repo: Schema.NullOr(Repository) }); +const decodeStack = decodeJsonResult( + Schema.Struct({ + number: StackNumber, + node_id: TrimmedNonEmptyString, + pull_requests: Schema.Array( + Schema.Struct({ + number: StackNumber, + title: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed"]), + draft: Schema.Boolean, + merged_at: Schema.NullOr(Schema.String), + head: Branch, + base: Branch, + }), + ), + }), +); + +/** The preview embeds repository API URLs, not `full_name`. Never guess from a short name. */ +function repositoryIdentity(url: string | undefined): string | null { + if (url === undefined) return null; + try { + const parsed = new URL(url); + if (parsed.origin !== "https://api.github.com") return null; + return /^\/repos\/([^/]+\/[^/]+)\/?$/u.exec(parsed.pathname)?.[1] ?? null; + } catch { + return null; + } +} + +/** Two bounded REST reads; a failed preview endpoint is unavailable, never an empty stack. */ +export const makeGitHubNativeStackRead = (execute: GitHubCli["Service"]["execute"]) => + Effect.fn("GitHubNativeStack.read")(function* ( + input: ProviderRepositoryRef & { readonly number: number; readonly limit: number }, + ) { + const failed = (detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "github", + operation: "getNativeDependencyMembership", + reason: "failed", + detail, + cause, + }); + // GitHub Enterprise versions have not been verified against this public preview. + if (input.host.toLowerCase() !== "github.com") { + return yield* failed("Native stack membership is unavailable on this GitHub host."); + } + const repository = input.repository.split("/").map(encodeURIComponent).join("/"); + const read = (path: string) => + execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "GET", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2026-03-10", + path, + ], + maxOutputBytes: 2 * 1024 * 1024, + }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "github", + operation: "getNativeDependencyMembership", + reason: + error._tag === "GitHubCliRateLimitError" + ? "rate-limited" + : error._tag === "GitHubCliAuthenticationError" + ? "unauthenticated" + : error._tag === "GitHubCliUnavailableError" + ? "missing-tool" + : "failed", + detail: error.detail, + cause: error, + }), + ), + Effect.flatMap((output) => + output.stdoutTruncated || output.stdoutInvalidUtf8 + ? Effect.fail(failed("Native stack response exceeded the read limit.")) + : Effect.succeed(output.stdout), + ), + ); + // The exact membership filter returns at most one stack; two rows detect a broken answer. + const listed = decodeList( + yield* read(`repos/${repository}/stacks?pull_request=${input.number}&per_page=2&page=1`), + ); + if (Result.isFailure(listed)) + return yield* failed("Unreadable native stack listing.", listed.failure); + if (listed.success.length === 0) + return { status: "none" } satisfies ProviderNativeDependencyMembership; + if (listed.success.length !== 1) return yield* failed("Native stack membership was ambiguous."); + const stackNumber = listed.success[0]!.number; + const decoded = decodeStack(yield* read(`repos/${repository}/stacks/${stackNumber}`)); + if (Result.isFailure(decoded)) + return yield* failed("Unreadable native stack members.", decoded.failure); + const stack = decoded.success; + if ( + stack.number !== stackNumber || + !stack.pull_requests.some((pr) => pr.number === input.number) || + new Set(stack.pull_requests.map((pr) => pr.number)).size !== stack.pull_requests.length || + stack.pull_requests.some( + (pr) => + repositoryIdentity(pr.base.repo?.url)?.toLowerCase() !== input.repository.toLowerCase(), + ) + ) { + return yield* failed( + "Native stack members did not match the requested repository and pull request.", + ); + } + const limit = Math.max(1, Math.min(input.limit, 100)); + // Keep the selected member visible even if it is beyond the display budget. + const focusIndex = stack.pull_requests.findIndex((pr) => pr.number === input.number); + const start = Math.max(0, focusIndex - limit + 1); + return { + status: "present", + id: stack.node_id, + coverage: stack.pull_requests.length > limit ? "partial" : "complete", + members: stack.pull_requests.slice(start, start + limit).map((pr) => ({ + number: pr.number, + title: pr.title, + url: pr.html_url, + state: pr.merged_at !== null ? "merged" : pr.state, + isDraft: pr.draft, + headBranch: pr.head.ref, + baseBranch: pr.base.ref, + headRepositoryNameWithOwner: repositoryIdentity(pr.head.repo?.url), + })), + } satisfies ProviderNativeDependencyMembership; + });