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
126 changes: 111 additions & 15 deletions apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
Expand Down Expand Up @@ -650,28 +651,123 @@ layer("GitHubPullRequestCli.layer", (it) => {
}),
);

it.effect("reads a host that refuses the stacks preview as not stacked", () =>
it.effect.each([
["gh: Not Found (HTTP 404)", "", null],
["HTTP 404: Not Found (https://github.example/api/v3/repos/acme/web/stacks)", "", null],
["gh: Resource not accessible by integration (HTTP 403)", undefined, "GitHubCliCommandError"],
["gh: Service Unavailable (HTTP 503)", undefined, "GitHubCliCommandError"],
["dial tcp: lookup github.example: no such host", undefined, "GitHubCliCommandError"],
[
"To get started with GitHub CLI, please run: gh auth login",
undefined,
"GitHubCliAuthenticationError",
],
["gh: Too Many Requests (HTTP 429)", undefined, "GitHubCliRateLimitError"],
["gh: Not Found (HTTP 404)", "gh: Not Found (HTTP 404)", "GitHubPullRequestNotFoundError"],
["gh: Not Found (HTTP 404)", "gh: Bad credentials (HTTP 401)", "GitHubCliCommandError"],
[
"gh: Not Found (HTTP 404)",
"gh: Resource not accessible by integration (HTTP 403)",
"GitHubCliCommandError",
],
[
"gh: Not Found (HTTP 404)",
"To get started with GitHub CLI, please run: gh auth login",
"GitHubCliAuthenticationError",
],
["gh: Not Found (HTTP 404)", "gh: Service Unavailable (HTTP 503)", "GitHubCliCommandError"],
["gh: Not Found (HTTP 404)", "gh: Too Many Requests (HTTP 429)", "GitHubCliRateLimitError"],
] as const)(
"classifies the raw stacks API failure: %s; PR access: %s",
([stderr, accessStderr, expectedError]) =>
Effect.gen(function* () {
const run = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
for (const response of accessStderr === undefined ? [stderr] : [stderr, accessStderr]) {
run.mockReturnValueOnce(
Effect.succeed({
stdout: "",
stderr: response,
code: ChildProcessSpawner.ExitCode(response === "" ? 0 : 1),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);
}
const vcs = yield* VcsProcess.make.pipe(
Effect.provideService(ProcessRunner.ProcessRunner, { run }),
);
const github = yield* GitHubCli.make.pipe(
Effect.provideService(VcsProcess.VcsProcess, vcs),
Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)),
);
const cli = yield* GitHubPullRequestCli.make.pipe(
Effect.provideService(GitHubCli.GitHubCli, github),
Effect.provide(GitHubGraphQlBudget.layer),
);
const read = cli.getPullRequestStack({
cwd: "/w",
repository: "acme/web",
host: "github.example",
number: 7,
includeDetails: true,
});
if (expectedError === null) {
assert.isNull(yield* read);
} else {
const error = yield* Effect.flip(read);
assert.strictEqual(error._tag, expectedError);
}
expect(run).toHaveBeenCalledTimes(accessStderr === undefined ? 1 : 2);
if (accessStderr !== undefined) {
expect(run.mock.calls[1]?.[0]?.args).toEqual([
"api",
"--hostname",
"github.example",
"repos/acme/web/pulls/7/commits?per_page=1",
"--silent",
]);
}
}),
);

it.effect("preserves a known stack when its detail request returns not found", () =>
Effect.gen(function* () {
// The CLI classifies a missing preview endpoint as not found.
mockedExecute.mockReturnValueOnce(
Effect.fail(
new GitHubCli.GitHubPullRequestNotFoundError({
command: "gh",
cwd: "/w",
cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
}),
Effect.succeed(
output(
encodeJson([
{
number: 3,
url: "https://api.github.com/repos/acme/web/stacks/3",
base: { ref: "main" },
pull_requests: [{ number: 7, head: { ref: "feat/two" }, state: "open" }],
},
]),
),
),
);
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

const stack = yield* cli.getPullRequestStack({
const failure = new GitHubCli.GitHubPullRequestNotFoundError({
command: "gh",
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
cause: new Error("gh: Not Found (HTTP 404)"),
});
mockedExecute.mockReturnValueOnce(Effect.fail(failure));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
const error = yield* Effect.flip(
cli.getPullRequestStack({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
includeDetails: true,
}),
);

assert.isNull(stack);
assert.strictEqual(error, failure);
expect(mockedExecute).toHaveBeenCalledTimes(2);
}),
);

Expand Down
22 changes: 17 additions & 5 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,23 @@ export const make = Effect.gen(function* () {
}),
);
}),
// A listing 404 can mean an unsupported stacks API or denied access. The PR
// commits endpoint requires the same pull-request read permission as stacks.
Effect.catchTags({
GitHubPullRequestNotFoundError: () =>
github
.execute({
cwd: input.cwd,
args: [
"api",
"--hostname",
input.host,
`repos/${owner}/${name}/pulls/${input.number}/commits?per_page=1`,
"--silent",
],
})
.pipe(Effect.as(null)),
}),
Effect.flatMap((stack) => {
if (!input.includeDetails || stack === null) return Effect.succeed(stack);
return github
Expand Down Expand Up @@ -2056,11 +2073,6 @@ export const make = Effect.gen(function* () {
}),
);
}),
// Hosts without the stacks preview return 404. Other failures must preserve the
// previously synced stack and let the caller retry.
Effect.catchTags({
GitHubPullRequestNotFoundError: () => Effect.succeed(null),
}),
);
},

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/vcs/VcsProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
(normalized.includes("could not resolve to a pullrequest") ||
normalized.includes("repository.pullrequest") ||
normalized.includes("no pull requests found for branch") ||
normalized.includes("http 404") ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High vcs/VcsProcess.ts:96

An inaccessible GitHub resource is classified as not-found, so an expired or insufficiently scoped token causes GitHubCli to raise GitHubPullRequestNotFoundError and getPullRequestStack to return null, discarding the existing stack and entering the non-stack merge flow. The generic http 404 match also covers GitHub's authorization-scoped 404 responses; remove it or restrict it to an explicit pull-request-not-found message so authentication failures remain actionable.

-        normalized.includes("http 404") ||
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/vcs/VcsProcess.ts around line 96:

An inaccessible GitHub resource is classified as `not-found`, so an expired or insufficiently scoped token causes `GitHubCli` to raise `GitHubPullRequestNotFoundError` and `getPullRequestStack` to return `null`, discarding the existing stack and entering the non-stack merge flow. The generic `http 404` match also covers GitHub's authorization-scoped 404 responses; remove it or restrict it to an explicit pull-request-not-found message so authentication failures remain actionable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bedb2a1. The fallback now applies only to the stacks listing and verifies PR-read access before returning null. It probes GET /repos/{owner}/{repo}/pulls/{number}/commits?per_page=1 with the same host and credentials; every probe failure propagates. Unlike the basic repository/PR endpoints, listing PR commits requires the same pull-request read permission as listing stacks.

A not-found error fetching an already discovered stack's details is no longer caught by the fallback either. This keeps access failures actionable and preserves the previous stack instead of clearing it.

Added regression coverage for access-probe 404/401/403, signed-out credentials, 429/503, and known-stack detail failures. All 312 focused tests pass, along with server typecheck and targeted lint. The read-only GHES check still returns a stacks 404 with a successful permission probe, preserving the original fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

normalized.includes("pull request not found"))) ||
(command === "glab" &&
(normalized.includes("merge request not found") ||
Expand Down
Loading