Skip to content
Merged
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
24 changes: 24 additions & 0 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubRepositoryCollaboratorPermission | null> {
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<string> {
if (!env.GITHUB_APP_PRIVATE_KEY) {
throw new Error("GitHub App credentials are not configured.");
Expand Down
45 changes: 41 additions & 4 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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<string | null> {
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);
Expand Down
36 changes: 36 additions & 0 deletions test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createOrUpdateSkippedGateCheckRun,
getAppInstallation,
getInstallationId,
getRepositoryCollaboratorPermission,
} from "../../src/github/app";
import type { Advisory } from "../../src/types";
import { createTestEnv } from "../helpers/d1";
Expand Down Expand Up @@ -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[] = [];
Expand Down
195 changes: 193 additions & 2 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1084,7 +1085,7 @@ describe("queue processors", () => {
"",
"- [x] <!-- gittensory-rerun-review:v1 --> 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();
Expand All @@ -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" } }]);
Expand All @@ -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("<!-- gittensory-pr-panel:v1 -->");
expect(patchedBody).toContain("Readiness score:");
expect(patchedBody).toContain("- [ ] <!-- gittensory-rerun-review:v1 --> Re-run Gittensory review");
Expand All @@ -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 = [
"<!-- gittensory-pr-panel:v1 -->",
"",
"- [x] <!-- gittensory-rerun-review:v1 --> 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 = [
"<!-- gittensory-pr-panel:v1 -->",
"",
"- [x] <!-- gittensory-rerun-review:v1 --> 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 = [
"<!-- gittensory-pr-panel:v1 -->",
"",
"- [x] <!-- gittensory-rerun-review:v1 --> 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 = [
Expand Down