Fix PR gate trusting author_association for org membership

author_association comes back as CONTRIBUTOR or NONE for team-based
contributors (e.g. members of the Contributor team), so the gate was
auto-closing PRs from people who clearly have write access.

Replace the author_association check with a live permission lookup via
repos.getCollaboratorPermissionLevel, which resolves direct, team, and
org access in one call. PRs from anyone with write/maintain/admin now
bypass the gate.
This commit is contained in:
evanpelle
2026-06-04 11:51:44 -07:00
parent 7e1d352469
commit 0ab437ed54
4 changed files with 39 additions and 17 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
export const REPO = { owner: "openfrontio", repo: "OpenFrontIO" } as const;
export const TRUSTED_AUTHOR_ASSOCIATIONS = [
"OWNER",
"MEMBER",
"COLLABORATOR",
] as const;
export const TRUSTED_REPO_PERMISSIONS = ["admin", "maintain", "write"] as const;
export const SMALL_FIX_LINE_THRESHOLD = 50;
+16 -1
View File
@@ -18,7 +18,6 @@ export async function getPR(
number: data.number,
body: data.body ?? null,
user: { login: data.user?.login ?? "" },
author_association: data.author_association,
labels: (data.labels ?? [])
.map((l) => l.name ?? "")
.filter((name) => name.length > 0),
@@ -37,6 +36,22 @@ export async function getPRFiles(
return files.map((f) => ({ additions: f.additions, deletions: f.deletions }));
}
export async function getRepoPermission(
octokit: Octokit,
username: string,
): Promise<string> {
try {
const { data } = await octokit.rest.repos.getCollaboratorPermissionLevel({
...REPO,
username,
});
return data.permission;
} catch (err) {
if (isStatus(err, 404)) return "none";
throw err;
}
}
export async function getIssue(
octokit: Octokit,
issueNumber: number,
+7 -1
View File
@@ -6,6 +6,7 @@ import {
getIssue,
getPR,
getPRFiles,
getRepoPermission,
makeOctokit,
postComment,
} from "./github";
@@ -57,7 +58,12 @@ async function main(): Promise<void> {
const pr = await getPR(octokit, prNumber);
const files = await getPRFiles(octokit, prNumber);
const decision = await evaluate(pr, files, (n) => getIssue(octokit, n));
const decision = await evaluate(
pr,
files,
(n) => getIssue(octokit, n),
(u) => getRepoPermission(octokit, u),
);
const prefix = `[pr-gate] PR #${prNumber}`;
if (decision.action === "pass") {
+15 -10
View File
@@ -2,17 +2,18 @@ import {
APPROVED_ISSUE_LABEL,
LABELS,
SMALL_FIX_LINE_THRESHOLD,
TRUSTED_AUTHOR_ASSOCIATIONS,
TRUSTED_REPO_PERMISSIONS,
} from "./config";
export type PRMetadata = {
number: number;
body: string | null;
user: { login: string };
author_association: string;
labels: string[];
};
export type GetRepoPermission = (username: string) => Promise<string>;
export type PRFile = {
additions: number;
deletions: number;
@@ -53,13 +54,16 @@ export function checkBypass(pr: PRMetadata): RuleResult {
return { action: "next" };
}
export function checkOrgMember(pr: PRMetadata): RuleResult {
if (
(TRUSTED_AUTHOR_ASSOCIATIONS as readonly string[]).includes(
pr.author_association,
)
) {
return { action: "pass", reason: `Author is ${pr.author_association}` };
export async function checkRepoAccess(
pr: PRMetadata,
getRepoPermission: GetRepoPermission,
): Promise<RuleResult> {
const permission = await getRepoPermission(pr.user.login);
if ((TRUSTED_REPO_PERMISSIONS as readonly string[]).includes(permission)) {
return {
action: "pass",
reason: `Author has "${permission}" permission on the repo`,
};
}
return { action: "next" };
}
@@ -104,11 +108,12 @@ export async function evaluate(
pr: PRMetadata,
files: PRFile[],
getIssue: GetIssue,
getRepoPermission: GetRepoPermission,
): Promise<RuleResult> {
const r0 = checkBypass(pr);
if (r0.action !== "next") return r0;
const r1 = checkOrgMember(pr);
const r1 = await checkRepoAccess(pr, getRepoPermission);
if (r1.action !== "next") return r1;
const r2 = await checkApprovedWork(pr, getIssue);