|
| 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