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
1 change: 0 additions & 1 deletion .github/workflows/type-label.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ on:
permissions:
contents: read
issues: write
pull-requests: write

concurrency:
group: type-label-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
Expand Down
13 changes: 13 additions & 0 deletions scripts/github-type-label.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export function getTypeLabelDecision(eventName, payload, options = {}) {
if (eventName === "pull_request_target") {
const pullRequest = payload.pull_request;
if (!pullRequest || typeof pullRequest !== "object") return { action: "skip", reason: "missing-pull-request" };
if (!isTrustedPullRequestForLabeling(pullRequest)) {
return { action: "skip", reason: "untrusted-pull-request-author", number: numberOrUndefined(pullRequest.number), title: stringOrEmpty(pullRequest.title) };
}

const decision = classifyPullRequestLabel(pullRequest, options.issueReferences ?? []);
if (!decision.label) return { action: "skip", reason: decision.reason, number: numberOrUndefined(pullRequest.number), title: stringOrEmpty(pullRequest.title) };
Expand Down Expand Up @@ -243,10 +246,20 @@ function shouldFetchReferencedIssues(eventName, payload) {
if (eventName !== "pull_request_target") return false;
const pullRequest = payload?.pull_request;
if (!pullRequest || typeof pullRequest !== "object") return false;
if (!isTrustedPullRequestForLabeling(pullRequest)) return false;
if (hasScoringLabel(normalizeLabels(pullRequest.labels))) return false;
return isFeatureTitle(String(pullRequest.title ?? "").trim());
}

function isTrustedPullRequestForLabeling(pullRequest) {
const association = String(pullRequest.author_association ?? "").toUpperCase();
if (["OWNER", "MEMBER", "COLLABORATOR"].includes(association)) return true;

const headRepository = pullRequest.head?.repo?.full_name;
const baseRepository = pullRequest.base?.repo?.full_name;
return typeof headRepository === "string" && headRepository.length > 0 && headRepository === baseRepository;
}

function classifyPullRequestLabel(pullRequest, issueReferences) {
const normalizedLabels = normalizeLabels(pullRequest.labels);
if (hasScoringLabel(normalizedLabels)) return { label: null, reason: "type-label-already-present" };
Expand Down
88 changes: 86 additions & 2 deletions test/unit/github-type-label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,13 @@ describe("GitHub type label classifier", () => {
).toMatchObject({ action: "skip", reason: "issue-is-pull-request" });
});

it("applies bug labels to pull_request_target payloads directly", () => {
it("applies bug labels to trusted pull_request_target payloads", () => {
expect(
getTypeLabelDecision("pull_request_target", {
pull_request: {
number: 42,
title: "fix(mcp): repair metadata boundary checks",
author_association: "MEMBER",
labels: [{ name: "size:S" }],
},
}),
Expand All @@ -75,11 +76,52 @@ describe("GitHub type label classifier", () => {
});
});

it("skips labels for untrusted fork pull_request_target payloads", () => {
expect(
getTypeLabelDecision("pull_request_target", {
pull_request: {
number: 42,
title: "fix(mcp): repair metadata boundary checks",
author_association: "NONE",
head: { repo: { full_name: "driveby/gittensory", fork: true } },
base: { repo: { full_name: "JSONbored/gittensory" } },
labels: [{ name: "size:S" }],
},
}),
).toEqual({
action: "skip",
reason: "untrusted-pull-request-author",
number: 42,
title: "fix(mcp): repair metadata boundary checks",
});
});

it("applies labels for same-repository pull_request_target payloads", () => {
expect(
getTypeLabelDecision("pull_request_target", {
pull_request: {
number: 44,
title: "fix(mcp): repair metadata boundary checks",
author_association: "NONE",
head: { repo: { full_name: "JSONbored/gittensory" } },
base: { repo: { full_name: "JSONbored/gittensory" } },
labels: [{ name: "size:S" }],
},
}),
).toEqual({
action: "apply",
label: "gittensor:bug",
number: 44,
title: "fix(mcp): repair metadata boundary checks",
});
});

it("requires a linked feature issue before labeling feature pull requests", () => {
const payload = {
pull_request: {
number: 43,
title: "feat(mcp): add metadata boundary checks",
author_association: "COLLABORATOR",
labels: [{ name: "size:S" }],
},
};
Expand Down Expand Up @@ -108,7 +150,7 @@ describe("GitHub type label classifier", () => {

expect(workflow).toMatch(/pull_request_target:/);
expect(workflow).toMatch(/issues:\s+write/);
expect(workflow).toMatch(/pull-requests:\s+write/);
expect(workflow).not.toMatch(/pull-requests:\s+write/);
expect(workflow).toContain("Checkout base branch");
expect(workflow).toContain("ref: ${{ github.event.repository.default_branch }}");
expect(workflow).toContain("persist-credentials: false");
Expand Down Expand Up @@ -290,6 +332,47 @@ describe("GitHub type label classifier", () => {
expect(issues).toEqual([{ number: 12, title: "[Feature]: add metadata boundary checks", labels: [{ name: "feature" }] }]);
});

it("does not fetch issue references for untrusted fork pull requests", async () => {
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const eventDir = mkdtempSync(join(tmpdir(), "type-label-"));
const eventPath = join(eventDir, "event.json");
writeFileSync(
eventPath,
JSON.stringify({
pull_request: {
number: 102,
title: "feat(mcp): add metadata boundary checks",
author_association: "NONE",
head: { repo: { full_name: "driveby/gittensory", fork: true } },
base: { repo: { full_name: "JSONbored/gittensory" } },
body: "Closes #12",
labels: [],
},
}),
);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const fetchMock = vi.fn(async () => {
throw new Error("fetch should not run for untrusted fork pull requests");
});
globalThis.fetch = fetchMock as typeof fetch;
process.env.GITHUB_EVENT_PATH = eventPath;
process.env.GITHUB_EVENT_NAME = "pull_request_target";
process.env.GITHUB_REPOSITORY = "JSONbored/gittensory";
process.env.GITHUB_TOKEN = "token";

try {
await main();
expect(fetchMock).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("type-label: skipped untrusted-pull-request-author");
} finally {
process.env = originalEnv;
globalThis.fetch = originalFetch;
log.mockRestore();
rmSync(eventDir, { recursive: true, force: true });
}
});

it("does not fetch issue references for non-feature pull requests", async () => {
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
Expand All @@ -301,6 +384,7 @@ describe("GitHub type label classifier", () => {
pull_request: {
number: 101,
title: "chore: update documentation",
author_association: "MEMBER",
body: "Closes #1 fixes #2 resolves #3",
labels: [],
},
Expand Down