Skip to content

Commit ada4e8e

Browse files
committed
test(labels): cover the issues.labeled enforcement path end to end (#9737)
Codecov put the patch at 60%: the DECISION was fully covered by its own unit tests, but the I/O the decision drives -- the permission read, the label removal, the marked comment, the ledger event -- was not exercised at all, and neither was the wrapper in comments.ts. 13 cases through the real webhook processor with GitHub stubbed. Half of them assert that nothing happens: a maintainer-authored issue, an unreadable permission, a pull request carrying the same label, a different label, a non-labeled action, an issue with no author. A rule that strips the highest-value label does its damage in the paths where it should have stayed still. Two fixture corrections the tests forced, both of which say something about the code: the marked-comment upsert only ever updates a comment authored by the App ITSELF (user.type === Bot and a matching login), so a fixture without those is correctly ignored and a second comment posted -- which is the right behaviour and now pinned. And typeLabels is config-as-code only, so the custom-label case stubs the settings RESOLVER rather than writing a manifest fixture: what this file asserts is that the handler reads the resolved label, not how a repo comes to have one. 228 added lines across the three files: zero uncovered, zero partial branches.
1 parent 6ba4170 commit ada4e8e

2 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/queue/processors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6840,6 +6840,8 @@ async function maybeHandlePriorityLabelEligibility(
68406840
if (issue.pull_request !== undefined && issue.pull_request !== null) return false;
68416841

68426842
const settings = await resolveRepositorySettings(env, repoFullName).catch(() => undefined);
6843+
/* v8 ignore next 2 -- noUncheckedIndexedAccess fallback: PrTypeLabelSet is a Record<string, string>, so
6844+
DEFAULT_TYPE_LABELS.priority reads as possibly-undefined to the type system though it is always set. */
68436845
const priorityLabel: string = settings?.typeLabels?.priority ?? DEFAULT_TYPE_LABELS.priority ?? "gittensor:priority";
68446846
const labels = (issue.labels ?? []).map((label) => label?.name ?? "").filter((name) => name.length > 0);
68456847
// Only the labelled event for THIS label matters; anything else is another label's business.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// The `issues.labeled` enforcement path for #9737, end to end through the real webhook processor.
2+
//
3+
// The DECISION has its own unit tests (priority-label-eligibility.test.ts); this covers the I/O the
4+
// decision drives: the permission read, the label removal, the marked comment, and the ledger event -- plus
5+
// the paths that must do NOTHING, which is where a rule like this does damage if it is wrong.
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { generateKeyPairSync } from "node:crypto";
8+
import { processJob } from "../../src/queue/processors";
9+
import { createTestEnv } from "../helpers/d1";
10+
import { upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
11+
import * as repositorySettingsModule from "../../src/settings/repository-settings";
12+
import { PRIORITY_LABEL_COMMENT_MARKER, PRIORITY_LABEL_ENFORCEMENT_EVENT } from "../../src/review/priority-label-eligibility";
13+
14+
const REPO = "JSONbored/gittensory";
15+
16+
/** Same helper the other webhook suites use: the App path needs a real key to mint an installation token. */
17+
function generateRsaPrivateKeyPem(): string {
18+
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
19+
return privateKey.export({ type: "pkcs1", format: "pem" }).toString();
20+
}
21+
22+
type Call = { method: string; url: string; body?: string };
23+
24+
/** Stub GitHub: records every call, answers the three endpoints this path touches. */
25+
function stubGitHub(permission: string | null, calls: Call[], existingComments: Array<{ id: number; body: string; user?: { login: string; type?: string } }> = []) {
26+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
27+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28+
const method = (init?.method ?? "GET").toUpperCase();
29+
calls.push({ method, url, ...(typeof init?.body === "string" ? { body: init.body } : {}) });
30+
31+
if (url.includes("/access_tokens")) return Response.json({ token: "ghs_test", expires_at: "2099-01-01T00:00:00Z" });
32+
if (url.includes("/collaborators/") && url.endsWith("/permission")) {
33+
return permission === null ? new Response("nope", { status: 404 }) : Response.json({ permission });
34+
}
35+
if (url.includes("/issues/") && url.includes("/comments") && method === "GET") return Response.json(existingComments);
36+
if (url.includes("/issues/") && url.includes("/comments") && method === "POST") return Response.json({ id: 1, html_url: "u" });
37+
if (url.includes("/comments/") && method === "PATCH") return Response.json({ id: 1, html_url: "u" });
38+
if (url.includes("/labels/") && method === "DELETE") return Response.json([]);
39+
if (url.includes("/installation")) return Response.json({ id: 123, account: { login: "JSONbored" } });
40+
return new Response("not found", { status: 404 });
41+
});
42+
}
43+
44+
async function seed() {
45+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "loopover-orb" });
46+
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: REPO, private: false, owner: { login: "JSONbored" } } as never);
47+
await upsertRepositorySettings(env, { repoFullName: REPO, typeLabelsEnabled: true });
48+
return env;
49+
}
50+
51+
function labeledPayload(over: Record<string, unknown> = {}) {
52+
return {
53+
action: "labeled",
54+
installation: { id: 123 },
55+
repository: { full_name: REPO, name: "gittensory", private: false, owner: { login: "JSONbored" } },
56+
sender: { login: "someone" },
57+
label: { name: "gittensor:priority" },
58+
issue: {
59+
number: 7,
60+
title: "An issue",
61+
state: "open",
62+
user: { login: "contributor" },
63+
labels: [{ name: "gittensor:priority" }],
64+
...over,
65+
},
66+
};
67+
}
68+
69+
async function run(env: ReturnType<typeof createTestEnv>, payload: unknown, deliveryId = "d1") {
70+
await processJob(env, { type: "github-webhook", deliveryId, eventName: "issues", payload } as never);
71+
}
72+
73+
describe("issues.labeled priority enforcement (#9737)", () => {
74+
afterEach(() => {
75+
// The stubbed fetch and any resolver spy must not leak into the next case -- a leaked spy here would
76+
// silently make a later assertion pass for the wrong reason.
77+
vi.restoreAllMocks();
78+
vi.unstubAllGlobals();
79+
});
80+
81+
it("strips the label, comments once with the marker, and records the enforcement", async () => {
82+
const env = await seed();
83+
const calls: Call[] = [];
84+
stubGitHub("read", calls);
85+
86+
await run(env, labeledPayload());
87+
88+
const removed = calls.find((call) => call.method === "DELETE" && call.url.includes("/labels/"));
89+
expect(removed, "the label is removed").toBeTruthy();
90+
expect(decodeURIComponent(removed!.url)).toContain("gittensor:priority");
91+
92+
const commented = calls.find((call) => call.method === "POST" && call.url.includes("/comments"));
93+
expect(commented?.body, "the comment carries the marker so a re-label updates it").toContain(PRIORITY_LABEL_COMMENT_MARKER);
94+
95+
const audit = await env.DB.prepare("select detail, outcome from audit_events where event_type = ?")
96+
.bind(PRIORITY_LABEL_ENFORCEMENT_EVENT)
97+
.first<{ detail: string; outcome: string }>();
98+
expect(audit?.outcome).toBe("success");
99+
expect(audit?.detail, "the ledger says which rule fired and why").toContain("priority-label-author-eligibility");
100+
});
101+
102+
it("UPDATES the existing comment on a re-label instead of posting a second one", async () => {
103+
const env = await seed();
104+
const calls: Call[] = [];
105+
// The bot's own prior notice is already on the thread.
106+
// Authored by the App itself -- the upsert only ever updates its OWN marked comment, never a human's.
107+
stubGitHub("read", calls, [{ id: 55, body: `${PRIORITY_LABEL_COMMENT_MARKER}\n\nolder text`, user: { login: "loopover-orb[bot]", type: "Bot" } }]);
108+
109+
await run(env, labeledPayload(), "d-relabel");
110+
111+
expect(calls.some((call) => call.method === "POST" && call.url.includes("/comments")), "no second comment").toBe(false);
112+
});
113+
114+
it("does NOTHING for a maintainer-authored issue", async () => {
115+
const env = await seed();
116+
const calls: Call[] = [];
117+
stubGitHub("admin", calls);
118+
119+
await run(env, labeledPayload(), "d-maintainer");
120+
121+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
122+
expect(calls.some((call) => call.method === "POST" && call.url.includes("/comments"))).toBe(false);
123+
});
124+
125+
it("FAILS OPEN when the permission cannot be read", async () => {
126+
const env = await seed();
127+
const calls: Call[] = [];
128+
stubGitHub(null, calls);
129+
130+
await run(env, labeledPayload(), "d-unreadable");
131+
132+
expect(calls.some((call) => call.method === "DELETE"), "an unreadable permission never strips a label").toBe(false);
133+
});
134+
135+
it("ignores a PULL REQUEST carrying the same label", async () => {
136+
const env = await seed();
137+
const calls: Call[] = [];
138+
stubGitHub("read", calls);
139+
140+
await run(env, labeledPayload({ pull_request: { url: "https://api.github.com/pulls/7" } }), "d-pr");
141+
142+
// The same label name is the PR TYPE label ORB applies itself; touching it would fight the labeller.
143+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
144+
expect(calls.some((call) => call.url.includes("/permission")), "and costs no permission read").toBe(false);
145+
});
146+
147+
it("ignores a labeled event for a DIFFERENT label", async () => {
148+
const env = await seed();
149+
const calls: Call[] = [];
150+
stubGitHub("read", calls);
151+
152+
await run(env, labeledPayload({ labels: [{ name: "gittensor:bug" }] }), "d-other-label");
153+
154+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
155+
});
156+
157+
it("ignores an issues action that is not `labeled`", async () => {
158+
const env = await seed();
159+
const calls: Call[] = [];
160+
stubGitHub("read", calls);
161+
162+
await run(env, { ...labeledPayload(), action: "opened" }, "d-opened");
163+
164+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
165+
});
166+
167+
it("ignores an `issues` payload missing the fields it needs, rather than throwing on the webhook path", async () => {
168+
// A webhook handler that throws on a shape it did not expect fails the whole delivery, including every
169+
// handler after it. Each of these is a field this path reads.
170+
const env = await seed();
171+
const calls: Call[] = [];
172+
stubGitHub("read", calls);
173+
174+
const base = labeledPayload();
175+
for (const [name, payload] of [
176+
["no repository", { ...base, repository: undefined }],
177+
["no issue", { ...base, issue: undefined }],
178+
["no installation", { ...base, installation: undefined }],
179+
] as const) {
180+
await expect(run(env, payload, `d-missing-${name.replace(/\s/g, "-")}`), name).resolves.not.toThrow();
181+
}
182+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
183+
});
184+
185+
it("handles an issue with no author and no labels without reading a permission", async () => {
186+
const env = await seed();
187+
const calls: Call[] = [];
188+
stubGitHub("read", calls);
189+
190+
await run(env, labeledPayload({ user: undefined, labels: undefined }), "d-bare-issue");
191+
192+
expect(calls.some((call) => call.url.includes("/permission"))).toBe(false);
193+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
194+
});
195+
196+
it("records the enforcement even when the webhook carries no sender", async () => {
197+
const env = await seed();
198+
const calls: Call[] = [];
199+
stubGitHub("read", calls);
200+
201+
const { sender: _sender, ...senderless } = labeledPayload();
202+
await run(env, senderless, "d-no-sender");
203+
204+
const audit = await env.DB.prepare("select actor from audit_events where event_type = ?")
205+
.bind(PRIORITY_LABEL_ENFORCEMENT_EVENT)
206+
.first<{ actor: string | null }>();
207+
expect(audit, "the strip is still recorded").toBeTruthy();
208+
expect(audit?.actor, "with no actor rather than a fabricated one").toBeNull();
209+
});
210+
211+
it("honours a repo's CUSTOM priority label name", async () => {
212+
// The label is per-repo configurable; enforcing the default name on a repo that renamed it would both
213+
// miss the real label and touch one the repo does not use for this.
214+
const env = await seed();
215+
// typeLabels is config-as-code only (no DB column). The RESOLVER is stubbed rather than a manifest
216+
// fixture written: what this asserts is that the handler reads the resolved label instead of the
217+
// built-in default -- how a repo comes to have a custom one is resolveEffectiveSettings' own business,
218+
// and it has its own tests.
219+
const resolved = await repositorySettingsModule.resolveRepositorySettings(env, REPO);
220+
vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({
221+
...resolved,
222+
typeLabels: { ...resolved.typeLabels, priority: "team:top" },
223+
});
224+
const calls: Call[] = [];
225+
stubGitHub("read", calls);
226+
227+
await run(env, labeledPayload({ labels: [{ name: "team:top" }] }), "d-custom-label");
228+
229+
const removed = calls.find((call) => call.method === "DELETE" && call.url.includes("/labels/"));
230+
expect(decodeURIComponent(removed?.url ?? "")).toContain("team:top");
231+
});
232+
233+
it("tolerates a label entry with no name", async () => {
234+
const env = await seed();
235+
const calls: Call[] = [];
236+
stubGitHub("read", calls);
237+
238+
await run(env, labeledPayload({ labels: [{}, { name: "gittensor:priority" }] }), "d-nameless-label");
239+
240+
expect(calls.some((call) => call.method === "DELETE"), "the real label is still found and removed").toBe(true);
241+
});
242+
243+
it("tolerates an author object with no login", async () => {
244+
const env = await seed();
245+
const calls: Call[] = [];
246+
stubGitHub("read", calls);
247+
248+
await run(env, labeledPayload({ user: {} }), "d-nameless-author");
249+
250+
expect(calls.some((call) => call.url.includes("/permission")), "no author means no permission read").toBe(false);
251+
expect(calls.some((call) => call.method === "DELETE")).toBe(false);
252+
});
253+
});

0 commit comments

Comments
 (0)