From 6e08f1e6ba8a0caa829bd53f7adf76e454cb8931 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:21:57 -0600 Subject: [PATCH] fix(github-app): authorize PR panel reruns --- src/github/app.ts | 24 +++++ src/queue/processors.ts | 45 +++++++- test/unit/github-app.test.ts | 36 +++++++ test/unit/queue.test.ts | 195 ++++++++++++++++++++++++++++++++++- 4 files changed, 294 insertions(+), 6 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index b6231f2533..6e2b0773c6 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -55,6 +55,30 @@ export async function getAppInstallation(env: Env, installationId: number): Prom return payload; } +export type GitHubRepositoryCollaboratorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none" | string; + +export async function getRepositoryCollaboratorPermission( + env: Env, + installationId: number, + repoFullName: string, + login: string, +): Promise { + const [owner, name] = repoFullName.split("/"); + if (!owner || !name || !login) return null; + const token = await createInstallationToken(env, installationId); + const response = await fetch( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(login)}/permission`, + { headers: githubHeaders(`Bearer ${token}`) }, + ); + if (response.status === 404) return null; + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to fetch GitHub collaborator permission (${response.status}): ${body.slice(0, 200)}`); + } + const payload = (await response.json()) as { permission?: GitHubRepositoryCollaboratorPermission }; + return payload.permission ?? null; +} + async function createAppJwt(env: Env): Promise { if (!env.GITHUB_APP_PRIVATE_KEY) { throw new Error("GitHub App credentials are not configured."); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index d6423e618f..9fd6554586 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -60,7 +60,7 @@ import { refreshInstallationHealth, } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot, type OfficialGittensorMinerDetection } from "../gittensor/api"; -import { createOrUpdateCheckRun, createOrUpdateGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId } from "../github/app"; +import { createOrUpdateCheckRun, createOrUpdateGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; import { createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; import { buildMaintainerQueueDigest, @@ -1117,15 +1117,44 @@ async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payloa await recordPrPanelRetriggerSkip(env, deliveryId, repoFullName, targetKey, actor, "missing_repo_pr_or_installation"); return true; } - const pr = await getPullRequest(env, repoFullName, issue.number); + const [pr, settings] = await Promise.all([getPullRequest(env, repoFullName, issue.number), getRepositorySettings(env, repoFullName)]); if (!pr) { await recordPrPanelRetriggerSkip(env, deliveryId, repoFullName, targetKey, actor, "cached_pr_missing"); return true; } - const [repo, settings, otherOpenPullRequests] = await Promise.all([ + const actorAssociation = await resolvePrPanelRetriggerActorAssociation(env, installationId, repoFullName, actor); + const pullRequestAuthor = pr.authorLogin ?? issue.user?.login ?? null; + const needsMinerDetection = commandAuthorizationNeedsMinerDetection({ + policy: settings.commandAuthorization, + commandName: "review-now", + commenterLogin: actor, + commenterAssociation: actorAssociation, + pullRequestAuthorLogin: pullRequestAuthor, + }); + const official = pullRequestAuthor && needsMinerDetection ? await getCachedOfficialMinerDetection(env, pullRequestAuthor, { targetKey: `${repoFullName}#${issue.number}`, deliveryId }) : undefined; + const authorization = isAuthorizedCommandActor({ + commandName: "review-now", + commenterLogin: actor, + commenterAssociation: actorAssociation, + pullRequestAuthorLogin: pullRequestAuthor, + officialAuthorDetection: official, + commandAuthorizationPolicy: settings.commandAuthorization, + }); + if (!authorization.authorized) { + await recordPrPanelRetriggerSkip(env, deliveryId, repoFullName, `${repoFullName}#${pr.number}`, actor, authorization.reason); + await recordGithubProductUsage(env, "pr_panel_retrigger_skipped", { + actor, + repoFullName, + targetKey: `${repoFullName}#${pr.number}`, + outcome: authorization.reason === "miner_detection_unavailable" ? "error" : "skipped", + metadata: { reason: authorization.reason, actorKind: authorization.actorKind, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "review-now") }, + }); + return true; + } + + const [repo, otherOpenPullRequests] = await Promise.all([ getRepository(env, repoFullName), - getRepositorySettings(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number), ]); const advisory = buildPullRequestAdvisory(repo, pr, { @@ -1154,6 +1183,14 @@ async function maybeProcessPrPanelRetrigger(env: Env, deliveryId: string, payloa return true; } +async function resolvePrPanelRetriggerActorAssociation(env: Env, installationId: number, repoFullName: string, actor: string | null): Promise { + if (!actor) return null; + const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, actor).catch(() => null); + if (permission === "admin" || permission === "maintain") return "MEMBER"; + if (permission === "write") return "COLLABORATOR"; + return null; +} + function isCheckedPrPanelRetrigger(body: string | null | undefined): boolean { if (!body?.includes(PR_PANEL_COMMENT_MARKER) || !body.includes(PR_PANEL_RETRIGGER_MARKER)) return false; return checkedMarkerRegex(PR_PANEL_RETRIGGER_MARKER).test(body); diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 2a7e3b7e31..90fc0e0449 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -8,6 +8,7 @@ import { createOrUpdateSkippedGateCheckRun, getAppInstallation, getInstallationId, + getRepositoryCollaboratorPermission, } from "../../src/github/app"; import type { Advisory } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -81,6 +82,41 @@ describe("GitHub check runs", () => { await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).resolves.toBe("installation-token"); }); + it("fetches repository collaborator permissions with installation credentials", async () => { + const privateKey = await generatePrivateKeyPem(); + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + calls.push(url); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/repos/JSONbored/gittensory/collaborators/maintainer/permission")) return Response.json({ permission: "maintain" }); + return new Response("not found", { status: 404 }); + }); + + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "maintainer")).resolves.toBe("maintain"); + expect(calls.some((url) => url.includes("/app/installations/123/access_tokens"))).toBe(true); + }); + + it("handles missing repository collaborator permission responses", async () => { + const privateKey = await generatePrivateKeyPem(); + + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "invalid", "maintainer")).resolves.toBeNull(); + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "")).resolves.toBeNull(); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/missing/permission")) return new Response("missing", { status: 404 }); + if (url.includes("/collaborators/no-permission/permission")) return Response.json({}); + if (url.includes("/collaborators/error/permission")) return new Response("permission unavailable", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "missing")).resolves.toBeNull(); + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "no-permission")).resolves.toBeNull(); + await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "error")).rejects.toThrow(/Failed to fetch GitHub collaborator permission/); + }); + it("updates an existing Gittensory check run for the same head SHA", async () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 50dbfe8fcf..9edbb06a14 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1068,6 +1068,7 @@ describe("queue processors", () => { checkRunMode: "off", gateCheckMode: "off", includeMaintainerAuthors: true, + commandAuthorization: { default: ["maintainer", "collaborator", "confirmed_miner"], commands: { "review-now": ["maintainer"] } }, }); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 45, @@ -1084,7 +1085,7 @@ describe("queue processors", () => { "", "- [x] Re-run Gittensory review", ].join("\n"); - const calls = { token: 0, minerList: 0, commentGets: 0, commentPatches: 0 }; + const calls = { token: 0, permission: 0, minerList: 0, commentGets: 0, commentPatches: 0 }; let patchedBody = ""; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -1099,6 +1100,10 @@ describe("queue processors", () => { calls.token += 1; return Response.json({ token: "installation-token" }); } + if (url.includes("/collaborators/maintainer/permission")) { + calls.permission += 1; + return Response.json({ permission: "maintain" }); + } if (url.includes("/issues/45/comments") && method === "GET") { calls.commentGets += 1; return Response.json([{ id: 777, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }]); @@ -1125,7 +1130,7 @@ describe("queue processors", () => { }, }); - expect(calls).toEqual({ token: 1, minerList: 1, commentGets: 1, commentPatches: 1 }); + expect(calls).toEqual({ token: 2, permission: 1, minerList: 1, commentGets: 1, commentPatches: 1 }); expect(patchedBody).toContain(""); expect(patchedBody).toContain("Readiness score:"); expect(patchedBody).toContain("- [ ] Re-run Gittensory review"); @@ -1143,6 +1148,192 @@ describe("queue processors", () => { expect(usageEvents).toEqual(expect.arrayContaining([expect.objectContaining({ surface: "github_app", eventName: "pr_panel_retriggered", outcome: "completed" })])); }); + it("skips PR panel reruns from users without repository write permission", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 46, + title: "Unauthorized panel refresh", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "panel-denied" }, + labels: [], + body: "Validation: npm test", + }); + const checkedPanel = [ + "", + "", + "- [x] Re-run Gittensory review", + ].join("\n"); + const calls = { token: 0, permission: 0, commentGets: 0, commentPatches: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + if (url.includes("/collaborators/drive-by-user/permission")) { + calls.permission += 1; + return Response.json({ permission: "read" }); + } + if (url.includes("/issues/46/comments")) { + calls.commentGets += 1; + return Response.json([{ id: 778, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }]); + } + if (url.includes("/issues/comments/778")) { + calls.commentPatches += 1; + return Response.json({ id: 778 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "panel-retrigger-denied", + eventName: "issue_comment", + payload: { + action: "edited", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 46, title: "Unauthorized panel refresh", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 778, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }, + sender: { login: "drive-by-user", type: "User" }, + }, + }); + + expect(calls).toEqual({ token: 1, permission: 1, commentGets: 0, commentPatches: 0 }); + const audit = await env.DB.prepare("select event_type, actor, target_key, outcome, detail from audit_events where event_type = ?") + .bind("github_app.pr_panel_retrigger_skipped") + .first<{ event_type: string; actor: string; target_key: string; outcome: string; detail: string }>(); + expect(audit).toMatchObject({ + event_type: "github_app.pr_panel_retrigger_skipped", + actor: "drive-by-user", + target_key: "JSONbored/gittensory#46", + outcome: "completed", + detail: "not_maintainer_or_pr_author", + }); + }); + + it("reruns the sticky PR panel when a write collaborator checks the rerun task", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicAudienceMode: "oss_maintainer", + publicSignalLevel: "standard", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "off", + includeMaintainerAuthors: true, + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 47, + title: "Refresh panel as collaborator", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "panel-writer" }, + labels: [], + body: "Validation: npm test", + }); + const checkedPanel = [ + "", + "", + "- [x] Re-run Gittensory review", + ].join("\n"); + const calls = { token: 0, permission: 0, minerList: 0, commentGets: 0, commentPatches: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") { + calls.minerList += 1; + return Response.json([]); + } + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor", public_repos: 2, followers: 1 }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + if (url.includes("/collaborators/writer/permission")) { + calls.permission += 1; + return Response.json({ permission: "write" }); + } + if (url.includes("/issues/47/comments") && method === "GET") { + calls.commentGets += 1; + return Response.json([{ id: 779, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }]); + } + if (url.includes("/issues/comments/779") && method === "PATCH") { + calls.commentPatches += 1; + return Response.json({ id: 779 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "panel-retrigger-writer", + eventName: "issue_comment", + payload: { + action: "edited", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 47, title: "Refresh panel as collaborator", state: "open", user: { login: "contributor" }, pull_request: {} }, + comment: { id: 779, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }, + sender: { login: "writer", type: "User" }, + }, + }); + + expect(calls).toEqual({ token: 2, permission: 1, minerList: 1, commentGets: 1, commentPatches: 1 }); + }); + + it("skips PR panel reruns when the editing actor and PR author are unavailable", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 48, + title: "Unknown panel refresh actor", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "panel-unknown" }, + labels: [], + body: "Validation: npm test", + }); + await env.DB.prepare("update pull_requests set author_login = null where repo_full_name = ? and number = ?").bind("JSONbored/gittensory", 48).run(); + const checkedPanel = [ + "", + "", + "- [x] Re-run Gittensory review", + ].join("\n"); + vi.stubGlobal("fetch", async () => new Response("unexpected fetch", { status: 500 })); + + await processJob(env, { + type: "github-webhook", + deliveryId: "panel-retrigger-unknown-actor", + eventName: "issue_comment", + payload: { + action: "edited", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 48, title: "Unknown panel refresh actor", state: "open", pull_request: {} }, + comment: { id: 780, body: checkedPanel, user: { login: "gittensory[bot]", type: "Bot" } }, + }, + }); + + const audit = await env.DB.prepare("select actor, target_key, detail from audit_events where event_type = ?") + .bind("github_app.pr_panel_retrigger_skipped") + .first<{ actor: string | null; target_key: string; detail: string }>(); + expect(audit).toMatchObject({ + actor: null, + target_key: "JSONbored/gittensory#48", + detail: "not_maintainer_or_pr_author", + }); + }); + it("ignores invalid rerun task edits and audits skipped rerun requests", async () => { const env = createTestEnv(); const checkedPanel = [