Skip to content
Closed
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: 3 additions & 1 deletion src/github/pr-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const ISSUE_EVENTS_RECENT_PAGE_LIMIT = 10;
// earliest reviews on a PR with a long review history and could dismiss (or miss) the wrong one.
const REVIEW_PAGE_SIZE = 100;
const REVIEW_PAGE_LIMIT = 10;
const PUBLIC_GITHUB_APP_SLUG = "gittensory-orb";

// The GitHub write primitives the maintainer auto-maintain layer (#778) uses to act on a PR's STATE — never
// its source. Thin wrappers over the installation-scoped REST API, mirroring labels.ts / comments.ts. Each
Expand Down Expand Up @@ -128,7 +129,8 @@ export async function dismissLatestBotApproval(env: Env, installationId: number,
const { owner, repo } = splitRepo(repoFullName);
return await withInstallationTokenRetry(env, installationId, async (token) => {
const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId));
const botLogin = `${env.GITHUB_APP_SLUG}[bot]`;
const botSlug = env.GITHUB_APP_SLUG?.trim() || PUBLIC_GITHUB_APP_SLUG;
const botLogin = `${botSlug}[bot]`;
// Reviews are returned oldest-first; the LAST matching entry across ALL pages is the bot's most recent
// APPROVE. Stopping at page 1 would find (or miss) the wrong review on a PR with >100 total reviews.
let latestApprovalId: number | undefined;
Expand Down
46 changes: 44 additions & 2 deletions test/unit/github-pr-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { closeIssue, closePullRequest, createIssueComment, createPullRequestRevi
import { clearInstallationTokenCacheForTest } from "../../src/github/app";
import { createTestEnv } from "../helpers/d1";

function envWithKey() {
return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() });
function envWithKey(overrides: Partial<Env> = {}) {
return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), ...overrides });
}

describe("GitHub PR action primitives (#778)", () => {
Expand Down Expand Up @@ -528,6 +528,48 @@ describe("GitHub PR action primitives (#778)", () => {
expect(calls[0]?.body).toMatchObject({ message: "stale approval retracted", event: "DISMISS" });
});

it("REGRESSION (#security): falls back to the public Orb bot when GITHUB_APP_SLUG is unset", async () => {
const env = envWithKey();
delete (env as Partial<Env>).GITHUB_APP_SLUG;
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url.includes("/access_tokens")) return Response.json({ token: "t" });
if (url.includes("/pulls/12/reviews") && !url.includes("/dismissals") && method === "GET") {
return Response.json([{ id: 12, state: "APPROVED", user: { login: "gittensory-orb[bot]" } }]);
}
if (url.includes("/pulls/12/reviews/12/dismissals") && method === "PUT") {
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {} });
return Response.json({ id: 12, state: "DISMISSED" });
}
return new Response("unexpected", { status: 500 });
});

await expect(dismissLatestBotApproval(env, 123, "owner/repo", 12, "retract")).resolves.toEqual({ dismissed: true });
expect(calls).toHaveLength(1);
});

it("falls back to the public Orb bot when GITHUB_APP_SLUG is blank", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url.includes("/access_tokens")) return Response.json({ token: "t" });
if (url.includes("/pulls/13/reviews") && !url.includes("/dismissals") && method === "GET") {
return Response.json([{ id: 13, state: "APPROVED", user: { login: "gittensory-orb[bot]" } }]);
}
if (url.includes("/pulls/13/reviews/13/dismissals") && method === "PUT") {
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {} });
return Response.json({ id: 13, state: "DISMISSED" });
}
return new Response("unexpected", { status: 500 });
});

await expect(dismissLatestBotApproval(envWithKey({ GITHUB_APP_SLUG: " " }), 123, "owner/repo", 13, "retract")).resolves.toEqual({ dismissed: true });
expect(calls).toHaveLength(1);
});

it("is a no-op when the bot never approved this PR", async () => {
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
Expand Down