Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
type GitHubReviewThreadPage,
type GitHubViewerAccess,
} from "./gitHubPullRequestJson.ts";
import { makeGitHubNativeStackRead } from "./gitHubNativeStack.ts";
import type { ProviderListCursor } from "./PullRequestProvider.ts";

/**
Expand Down Expand Up @@ -407,6 +408,8 @@ export interface GitHubPullRequestDiffSlice {
export class GitHubPullRequestCli extends Context.Service<
GitHubPullRequestCli,
{
readonly getNativeDependencyMembership: ReturnType<typeof makeGitHubNativeStackRead>;

readonly getViewerLogin: (input: {
readonly cwd: string;
}) => Effect.Effect<string, GitHubPullRequestCliError>;
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))),

Expand Down
135 changes: 135 additions & 0 deletions apps/server/src/pullRequest/gitHubNativeStack.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, truncated = false) {
const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const encoded = responses.map((response) => encode(response));
const requests: ReadonlyArray<string>[] = [];
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);
}),
);
153 changes: 153 additions & 0 deletions apps/server/src/pullRequest/gitHubNativeStack.ts
Original file line number Diff line number Diff line change
@@ -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;
});