diff --git a/scripts/features/lib/classify.mjs b/scripts/features/lib/classify.mjs new file mode 100644 index 0000000000..300222c511 --- /dev/null +++ b/scripts/features/lib/classify.mjs @@ -0,0 +1,93 @@ +/** + * Issue classification: quarantine filter + engagement override + skip rules. + */ + +const HARDCODED_BOTS = new Set(["github-actions", "dependabot", "renovate", "claude", "copilot"]); + +export function isBot(user) { + if (!user) return false; + if (user.__typename === "Bot") return true; + const login = user.login || ""; + if (login.endsWith("[bot]")) return true; + if (HARDCODED_BOTS.has(login)) return true; + return false; +} + +function countThumbs(reactionGroups) { + if (!Array.isArray(reactionGroups)) return 0; + const g = reactionGroups.find((rg) => rg.content === "THUMBS_UP"); + return g?.users?.totalCount ?? 0; +} + +function uniqueCommenters(comments, authorLogin) { + if (!Array.isArray(comments)) return []; + const seen = new Set(); + for (const c of comments) { + const u = c.author; + if (!u || !u.login) continue; + if (u.login === authorLogin) continue; + if (isBot(u)) continue; + seen.add(u.login); + } + return [...seen]; +} + +function openLinkedPrs(timelineItems) { + if (!Array.isArray(timelineItems)) return []; + const result = []; + for (const item of timelineItems) { + if (item.__typename !== "CrossReferencedEvent") continue; + const src = item.source; + if (!src || src.__typename !== "PullRequest") continue; + if (String(src.state).toLowerCase() !== "open") continue; + result.push({ number: src.number, state: "open" }); + } + return result; +} + +export function classifyIssue(issue, thresholds, now = new Date()) { + const assignees = issue.assignees ?? []; + if (assignees.length > 0) { + return { + bucket: "skip_assigned", + reason: "issue has assignees", + assignees: assignees.map((a) => a.login), + }; + } + + const linkedPrs = openLinkedPrs(issue.timelineItems); + if (linkedPrs.length > 0) { + return { + bucket: "skip_has_pr", + reason: "issue has open linked PR", + linkedPrs, + }; + } + + const createdAt = new Date(issue.createdAt).getTime(); + let ageDays = Math.floor((now.getTime() - createdAt) / 86400_000); + if (ageDays < 0) ageDays = 0; + + const thumbs = countThumbs(issue.reactionGroups); + const commenterLogins = uniqueCommenters(issue.comments, issue.author?.login); + const commenters = commenterLogins.length; + + const meta = { ageDays, thumbs, commenters }; + + if (ageDays >= thresholds.quarantineDays) { + return { bucket: "absorb", reason: `age>=${thresholds.quarantineDays}`, ...meta }; + } + + const overrides = []; + if (thumbs >= thresholds.overrideThumbs) overrides.push("thumbs"); + if (commenters >= thresholds.overrideCommenters) overrides.push("commenters"); + if (overrides.length > 0) { + return { bucket: "absorb", reason: `override:${overrides.join("+")}`, ...meta }; + } + + return { + bucket: "dormant", + reason: `age<${thresholds.quarantineDays} && thumbs<${thresholds.overrideThumbs} && commenters<${thresholds.overrideCommenters}`, + ...meta, + }; +} diff --git a/tests/unit/feature-triage/classify.test.mjs b/tests/unit/feature-triage/classify.test.mjs new file mode 100644 index 0000000000..02c9cc0d11 --- /dev/null +++ b/tests/unit/feature-triage/classify.test.mjs @@ -0,0 +1,162 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { classifyIssue, isBot } from "../../../scripts/features/lib/classify.mjs"; + +const DEFAULT_THRESHOLDS = { + quarantineDays: 14, + overrideThumbs: 5, + overrideCommenters: 3, +}; + +function makeIssue(overrides = {}) { + const now = new Date("2026-05-19T00:00:00Z"); + return { + number: 1046, + title: "Test", + url: "https://github.com/x/y/issues/1046", + author: { login: "alice" }, + createdAt: new Date(now.getTime() - 3 * 86400_000).toISOString(), + labels: [{ name: "enhancement" }], + assignees: [], + comments: [], + reactionGroups: [], + timelineItems: [], + ...overrides, + }; +} + +describe("isBot", () => { + it("detects [bot] suffix", () => { + assert.equal(isBot({ login: "dependabot[bot]" }), true); + }); + it("detects hardcoded bot names", () => { + assert.equal(isBot({ login: "github-actions" }), true); + assert.equal(isBot({ login: "claude" }), true); + assert.equal(isBot({ login: "copilot" }), true); + }); + it("detects Bot via __typename", () => { + assert.equal(isBot({ login: "anything", __typename: "Bot" }), true); + }); + it("returns false for regular user", () => { + assert.equal(isBot({ login: "alice" }), false); + }); +}); + +describe("classifyIssue", () => { + const now = new Date("2026-05-19T00:00:00Z"); + + it("3 days old, no engagement → dormant", () => { + const r = classifyIssue(makeIssue(), DEFAULT_THRESHOLDS, now); + assert.equal(r.bucket, "dormant"); + assert.match(r.reason, /age<14/); + }); + + it("3 days old, 5 thumbs → absorb (override:thumbs)", () => { + const r = classifyIssue( + makeIssue({ + reactionGroups: [{ content: "THUMBS_UP", users: { totalCount: 5 } }], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "absorb"); + assert.match(r.reason, /override:thumbs/); + assert.equal(r.thumbs, 5); + }); + + it("3 days old, 3 unique non-bot non-author commenters → absorb (override:commenters)", () => { + const r = classifyIssue( + makeIssue({ + comments: [ + { author: { login: "bob" } }, + { author: { login: "carol" } }, + { author: { login: "dave" } }, + ], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "absorb"); + assert.match(r.reason, /override:commenters/); + assert.equal(r.commenters, 3); + }); + + it("3 days old, 4 commenters but 1 is bot → dormant", () => { + const r = classifyIssue( + makeIssue({ + comments: [ + { author: { login: "bob" } }, + { author: { login: "carol" } }, + { author: { login: "dependabot[bot]" } }, + ], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "dormant"); + assert.equal(r.commenters, 2); + }); + + it("3 days old, commenters include author → author excluded", () => { + const r = classifyIssue( + makeIssue({ + comments: [ + { author: { login: "alice" } }, + { author: { login: "bob" } }, + { author: { login: "carol" } }, + ], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "dormant"); + assert.equal(r.commenters, 2); + }); + + it("exactly 14 days old → absorb (age>=14)", () => { + const issue = makeIssue({ + createdAt: new Date(now.getTime() - 14 * 86400_000).toISOString(), + }); + const r = classifyIssue(issue, DEFAULT_THRESHOLDS, now); + assert.equal(r.bucket, "absorb"); + assert.match(r.reason, /age>=14/); + }); + + it("20 days old with assignee → skip_assigned (precedence)", () => { + const r = classifyIssue( + makeIssue({ + createdAt: new Date(now.getTime() - 20 * 86400_000).toISOString(), + assignees: [{ login: "alice" }], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "skip_assigned"); + }); + + it("20 days old with open linked PR → skip_has_pr (precedence)", () => { + const r = classifyIssue( + makeIssue({ + createdAt: new Date(now.getTime() - 20 * 86400_000).toISOString(), + timelineItems: [ + { + __typename: "CrossReferencedEvent", + source: { __typename: "PullRequest", state: "OPEN", number: 2400 }, + }, + ], + }), + DEFAULT_THRESHOLDS, + now + ); + assert.equal(r.bucket, "skip_has_pr"); + assert.deepEqual(r.linkedPrs, [{ number: 2400, state: "open" }]); + }); + + it("custom thresholds (quarantineDays=7) respected", () => { + const issue = makeIssue({ + createdAt: new Date(now.getTime() - 8 * 86400_000).toISOString(), + }); + const r = classifyIssue(issue, { ...DEFAULT_THRESHOLDS, quarantineDays: 7 }, now); + assert.equal(r.bucket, "absorb"); + }); +});