mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(triage): add classifyIssue with quarantine + engagement override
This commit is contained in:
93
scripts/features/lib/classify.mjs
Normal file
93
scripts/features/lib/classify.mjs
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
162
tests/unit/feature-triage/classify.test.mjs
Normal file
162
tests/unit/feature-triage/classify.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user