mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-17 05:34:37 +00:00
claude
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DigestInputs } from "../claude";
|
||||
import {
|
||||
ageDays,
|
||||
formatIsoDate,
|
||||
renderFallbackDigest,
|
||||
truncateForDiscord,
|
||||
} from "../digest-formatter";
|
||||
|
||||
function emptyInputs(): DigestInputs {
|
||||
return {
|
||||
today: "2026-05-29",
|
||||
triageReadyInWindow: [],
|
||||
needsMaintainerTriage: [],
|
||||
needsInfoCount: 0,
|
||||
staleClosingSoon: [],
|
||||
awaitingMilestone: [],
|
||||
prReviews: [],
|
||||
triageSummary: { processed: 0, closed: 0, kept: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatIsoDate", () => {
|
||||
it("renders YYYY-MM-DD", () => {
|
||||
expect(formatIsoDate(new Date("2026-05-29T12:34:56Z"))).toBe("2026-05-29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFallbackDigest", () => {
|
||||
it("includes only sections with content", () => {
|
||||
const out = renderFallbackDigest(emptyInputs());
|
||||
expect(out).toContain("# Daily Triage — 2026-05-29");
|
||||
expect(out).not.toContain("🟢 Newly classified");
|
||||
expect(out).not.toContain("🟡 Needs your judgment");
|
||||
expect(out).not.toContain("🔴 Auto-closing");
|
||||
expect(out).not.toContain("🟠 Awaiting your milestone");
|
||||
expect(out).not.toContain("🔵 PRs needing review");
|
||||
});
|
||||
|
||||
it("emits the newly-classified section with each issue", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
triageReadyInWindow: [
|
||||
{
|
||||
number: 100,
|
||||
title: "Crash on right-click",
|
||||
author: "alice",
|
||||
area: "area:client",
|
||||
},
|
||||
{ number: 101, title: "Hotkey for chat", author: "bob", area: null },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟢 Newly classified by Claude (2)");
|
||||
expect(out).toContain(
|
||||
"#100 [area:client] — Crash on right-click (by @alice)",
|
||||
);
|
||||
expect(out).toContain("#101 — Hotkey for chat (by @bob)");
|
||||
});
|
||||
|
||||
it("emits the needs-judgment section with reasoning when present", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
needsMaintainerTriage: [
|
||||
{ number: 5, title: "ambiguous report", reasoning: "could be PEBKAC" },
|
||||
{ number: 6, title: "no reasoning case" },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟡 Needs your judgment (2)");
|
||||
expect(out).toContain("#5 — ambiguous report _(could be PEBKAC)_");
|
||||
expect(out).toContain("#6 — no reasoning case");
|
||||
});
|
||||
|
||||
it("emits the awaiting-milestone section with all entries, tags, and age", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
awaitingMilestone: [
|
||||
{
|
||||
number: 45,
|
||||
title: "Crash on right-click",
|
||||
ageDays: 21,
|
||||
primaryLabel: "bug",
|
||||
area: "area:client",
|
||||
},
|
||||
{
|
||||
number: 76,
|
||||
title: "Add chat hotkey",
|
||||
ageDays: 12,
|
||||
primaryLabel: "qol-improvement",
|
||||
area: null,
|
||||
},
|
||||
{
|
||||
number: 99,
|
||||
title: "Just opened",
|
||||
ageDays: 0,
|
||||
primaryLabel: null,
|
||||
area: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🟠 Awaiting your milestone (3)");
|
||||
expect(out).toContain(
|
||||
"#45 [bug, area:client] — 21d — Crash on right-click",
|
||||
);
|
||||
expect(out).toContain("#76 [qol-improvement] — 12d — Add chat hotkey");
|
||||
expect(out).toContain("#99 — 0d — Just opened");
|
||||
});
|
||||
|
||||
it("emits the PR review section with risk/tests/scope summary", () => {
|
||||
const out = renderFallbackDigest({
|
||||
...emptyInputs(),
|
||||
prReviews: [
|
||||
{
|
||||
pr: { number: 220, title: "tick refactor", author: "carol" },
|
||||
review: {
|
||||
summary: "refactors tick loop",
|
||||
risk: "high",
|
||||
tests_present: "yes",
|
||||
scope_match: "matches",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toContain("🔵 PRs needing review (1)");
|
||||
expect(out).toContain(
|
||||
"#220 by @carol — refactors tick loop · risk: high · tests: yes · scope: matches",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a needs-info count when nonzero", () => {
|
||||
const out = renderFallbackDigest({ ...emptyInputs(), needsInfoCount: 4 });
|
||||
expect(out).toContain("4 issues waiting on reporter");
|
||||
});
|
||||
|
||||
it("omits needs-info line when count is zero", () => {
|
||||
expect(renderFallbackDigest(emptyInputs())).not.toContain(
|
||||
"waiting on reporter",
|
||||
);
|
||||
});
|
||||
|
||||
it("always tags itself as the fallback", () => {
|
||||
expect(renderFallbackDigest(emptyInputs())).toContain("Fallback digest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ageDays", () => {
|
||||
it("rounds down to whole days", () => {
|
||||
const now = new Date("2026-05-30T12:00:00Z");
|
||||
expect(ageDays("2026-05-30T11:00:00Z", now)).toBe(0);
|
||||
expect(ageDays("2026-05-29T11:00:00Z", now)).toBe(1);
|
||||
expect(ageDays("2026-05-09T12:00:00Z", now)).toBe(21);
|
||||
});
|
||||
|
||||
it("returns 0 for future-dated input (clock skew)", () => {
|
||||
const now = new Date("2026-05-30T12:00:00Z");
|
||||
expect(ageDays("2026-06-01T00:00:00Z", now)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unparseable input", () => {
|
||||
expect(ageDays("not-a-date", new Date())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateForDiscord", () => {
|
||||
it("returns the input unchanged when under the limit", () => {
|
||||
expect(truncateForDiscord("short")).toBe("short");
|
||||
});
|
||||
|
||||
it("truncates and appends a marker when over the limit", () => {
|
||||
const long = "a".repeat(2000);
|
||||
const out = truncateForDiscord(long, 100);
|
||||
expect(out.endsWith("…(truncated)")).toBe(true);
|
||||
expect(out.length).toBeLessThan(long.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FORBIDDEN_LABELS } from "../config";
|
||||
import { assertNotForbidden, type Action } from "../github";
|
||||
|
||||
describe("assertNotForbidden", () => {
|
||||
it("allows non-forbidden labels", () => {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "add_label", label: "bug" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "remove_label", label: "needs-info" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when trying to add a Layer A label", () => {
|
||||
for (const forbidden of FORBIDDEN_LABELS) {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "add_label", label: forbidden }),
|
||||
).toThrow(/forbidden label/);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when trying to remove a Layer A label", () => {
|
||||
for (const forbidden of FORBIDDEN_LABELS) {
|
||||
expect(() =>
|
||||
assertNotForbidden({ type: "remove_label", label: forbidden }),
|
||||
).toThrow(/forbidden label/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw for comment / close actions", () => {
|
||||
const actions: Action[] = [
|
||||
{ type: "comment", body: "hello" },
|
||||
{ type: "close", reason: "not_planned" },
|
||||
];
|
||||
for (const a of actions) {
|
||||
expect(() => assertNotForbidden(a)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("FORBIDDEN_LABELS contains exactly the Layer A labels", () => {
|
||||
expect(FORBIDDEN_LABELS.has("approved")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("not-approved")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("stale")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("keep-open")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("auto-closed-stale")).toBe(true);
|
||||
expect(FORBIDDEN_LABELS.has("bug")).toBe(false);
|
||||
expect(FORBIDDEN_LABELS.has("needs-info")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TriageDecision } from "../claude";
|
||||
import { ENABLE_TRANSLATION_CLOSE, LABELS } from "../config";
|
||||
import type { Issue } from "../github";
|
||||
import {
|
||||
classificationClosesIssue,
|
||||
decisionToActions,
|
||||
downgradeIfLowConfidence,
|
||||
isOrgMemberIssueNeedingTag,
|
||||
isTriageCandidate,
|
||||
} from "../pass1-triage";
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
number: 1,
|
||||
title: "Test issue",
|
||||
body: "Test body",
|
||||
state: "open",
|
||||
labels: [],
|
||||
user: { login: "alice" },
|
||||
author_association: "NONE",
|
||||
created_at: "2026-05-29T00:00:00Z",
|
||||
updated_at: "2026-05-29T00:00:00Z",
|
||||
is_pull_request: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isTriageCandidate", () => {
|
||||
it("accepts a clean open issue from an outside contributor", () => {
|
||||
expect(isTriageCandidate(makeIssue())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects pull requests", () => {
|
||||
expect(isTriageCandidate(makeIssue({ is_pull_request: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects closed issues", () => {
|
||||
expect(isTriageCandidate(makeIssue({ state: "closed" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects already-triaged issues", () => {
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: [LABELS.CLAUDE_TRIAGED] })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects issues from org members", () => {
|
||||
for (const assoc of ["OWNER", "MEMBER", "COLLABORATOR"]) {
|
||||
expect(isTriageCandidate(makeIssue({ author_association: assoc }))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects issues that have any auto-closed-* label", () => {
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: ["auto-closed-feature"] })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTriageCandidate(makeIssue({ labels: ["auto-closed-support"] })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOrgMemberIssueNeedingTag", () => {
|
||||
it("flags org-member issues without claude-triaged", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(makeIssue({ author_association: "MEMBER" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag org-member issues already tagged", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(
|
||||
makeIssue({
|
||||
author_association: "MEMBER",
|
||||
labels: [LABELS.CLAUDE_TRIAGED],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag outside-contributor issues", () => {
|
||||
expect(
|
||||
isOrgMemberIssueNeedingTag(makeIssue({ author_association: "NONE" })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downgradeIfLowConfidence", () => {
|
||||
const cases: Array<{
|
||||
classification: TriageDecision["classification"];
|
||||
confidence: TriageDecision["confidence"];
|
||||
expectDowngrade: boolean;
|
||||
}> = [
|
||||
{ classification: "feature", confidence: "low", expectDowngrade: true },
|
||||
{ classification: "feature", confidence: "medium", expectDowngrade: true },
|
||||
{ classification: "feature", confidence: "high", expectDowngrade: false },
|
||||
{ classification: "question", confidence: "medium", expectDowngrade: true },
|
||||
{ classification: "support", confidence: "low", expectDowngrade: true },
|
||||
{ classification: "billing", confidence: "high", expectDowngrade: false },
|
||||
{ classification: "security", confidence: "low", expectDowngrade: false },
|
||||
{ classification: "security", confidence: "high", expectDowngrade: false },
|
||||
{
|
||||
classification: "translation",
|
||||
confidence: "medium",
|
||||
expectDowngrade: true,
|
||||
},
|
||||
{ classification: "bug", confidence: "low", expectDowngrade: false },
|
||||
{
|
||||
classification: "qol-improvement",
|
||||
confidence: "low",
|
||||
expectDowngrade: false,
|
||||
},
|
||||
{
|
||||
classification: "needs-info",
|
||||
confidence: "medium",
|
||||
expectDowngrade: false,
|
||||
},
|
||||
{ classification: "duplicate", confidence: "low", expectDowngrade: false },
|
||||
{ classification: "uncertain", confidence: "low", expectDowngrade: false },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.classification} @ ${c.confidence} → ${c.expectDowngrade ? "downgrade" : "keep"}`, () => {
|
||||
const input: TriageDecision = {
|
||||
classification: c.classification,
|
||||
confidence: c.confidence,
|
||||
reasoning: "test",
|
||||
};
|
||||
const out = downgradeIfLowConfidence(input);
|
||||
if (c.expectDowngrade) {
|
||||
expect(out.classification).toBe("uncertain");
|
||||
expect(out.reasoning).toContain("downgraded");
|
||||
} else {
|
||||
expect(out).toBe(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("decisionToActions", () => {
|
||||
it("bug → bug + area + claude-approved + claude-triaged", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "bug",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
suggested_area: "area:client",
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.BUG,
|
||||
"area:client",
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bug without area still claude-approved and triaged", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "bug", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.BUG,
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("qol-improvement mirrors bug structure with qol label", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "qol-improvement",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
suggested_area: "area:core",
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labelsAdded = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labelsAdded).toEqual([
|
||||
LABELS.QOL_IMPROVEMENT,
|
||||
"area:core",
|
||||
LABELS.CLAUDE_APPROVED,
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
]);
|
||||
});
|
||||
|
||||
it("duplicate adds possible-duplicate, posts dup comment, does NOT close", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "duplicate",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
duplicate_of: 42,
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(LABELS.POSSIBLE_DUPLICATE);
|
||||
expect(labels).toContain(LABELS.CLAUDE_TRIAGED);
|
||||
const comment = actions.find((a) => a.type === "comment");
|
||||
expect(comment).toBeDefined();
|
||||
expect((comment as { body: string }).body).toContain("#42");
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("needs-info includes only the questions Claude provided", () => {
|
||||
const actions = decisionToActions(
|
||||
{
|
||||
classification: "needs-info",
|
||||
confidence: "high",
|
||||
reasoning: "x",
|
||||
clarifying_questions: ["Which browser?", "What did you click?"],
|
||||
},
|
||||
makeIssue(),
|
||||
);
|
||||
const comment = actions.find((a) => a.type === "comment");
|
||||
expect(comment).toBeDefined();
|
||||
expect((comment as { body: string }).body).toContain("Which browser?");
|
||||
expect((comment as { body: string }).body).toContain("What did you click?");
|
||||
});
|
||||
|
||||
it("needs-info with no questions adds label but skips comment", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "needs-info", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("each auto-close classification adds correct label, comment, close", () => {
|
||||
const expectations: Array<{
|
||||
cls: TriageDecision["classification"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ cls: "feature", label: LABELS.AUTO_CLOSED_FEATURE },
|
||||
{ cls: "question", label: LABELS.AUTO_CLOSED_QUESTION },
|
||||
{ cls: "support", label: LABELS.AUTO_CLOSED_SUPPORT },
|
||||
{ cls: "billing", label: LABELS.AUTO_CLOSED_BILLING },
|
||||
];
|
||||
for (const { cls, label } of expectations) {
|
||||
const actions = decisionToActions(
|
||||
{ classification: cls, confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(label);
|
||||
expect(labels).toContain(LABELS.CLAUDE_TRIAGED);
|
||||
expect(actions.find((a) => a.type === "comment")).toBeDefined();
|
||||
expect(actions.find((a) => a.type === "close")).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("security is never auto-closed; routes to claude-uncertain (no comment, no close)", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "security", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toEqual([LABELS.CLAUDE_UNCERTAIN, LABELS.CLAUDE_TRIAGED]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("translation auto-closes only when ENABLE_TRANSLATION_CLOSE is true", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "translation", confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
if (ENABLE_TRANSLATION_CLOSE) {
|
||||
expect(actions.find((a) => a.type === "close")).toBeDefined();
|
||||
} else {
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toContain(LABELS.CLAUDE_UNCERTAIN);
|
||||
}
|
||||
});
|
||||
|
||||
it("uncertain adds claude-uncertain and claude-triaged only", () => {
|
||||
const actions = decisionToActions(
|
||||
{ classification: "uncertain", confidence: "low", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const labels = actions
|
||||
.filter((a) => a.type === "add_label")
|
||||
.map((a) => (a as { label: string }).label);
|
||||
expect(labels).toEqual([LABELS.CLAUDE_UNCERTAIN, LABELS.CLAUDE_TRIAGED]);
|
||||
expect(actions.find((a) => a.type === "close")).toBeUndefined();
|
||||
expect(actions.find((a) => a.type === "comment")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("always ends with the claude-triaged label", () => {
|
||||
const all: TriageDecision["classification"][] = [
|
||||
"bug",
|
||||
"qol-improvement",
|
||||
"duplicate",
|
||||
"needs-info",
|
||||
"feature",
|
||||
"question",
|
||||
"support",
|
||||
"billing",
|
||||
"security",
|
||||
"translation",
|
||||
"uncertain",
|
||||
];
|
||||
for (const cls of all) {
|
||||
const actions = decisionToActions(
|
||||
{ classification: cls, confidence: "high", reasoning: "x" },
|
||||
makeIssue(),
|
||||
);
|
||||
const lastAddLabel = [...actions]
|
||||
.reverse()
|
||||
.find((a) => a.type === "add_label");
|
||||
expect((lastAddLabel as { label: string }).label).toBe(
|
||||
LABELS.CLAUDE_TRIAGED,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("classificationClosesIssue", () => {
|
||||
it("auto-close categories return true (translation depends on flag)", () => {
|
||||
expect(classificationClosesIssue("feature")).toBe(true);
|
||||
expect(classificationClosesIssue("question")).toBe(true);
|
||||
expect(classificationClosesIssue("support")).toBe(true);
|
||||
expect(classificationClosesIssue("billing")).toBe(true);
|
||||
expect(classificationClosesIssue("translation")).toBe(
|
||||
ENABLE_TRANSLATION_CLOSE,
|
||||
);
|
||||
});
|
||||
|
||||
it("non-close categories return false (including security)", () => {
|
||||
expect(classificationClosesIssue("bug")).toBe(false);
|
||||
expect(classificationClosesIssue("qol-improvement")).toBe(false);
|
||||
expect(classificationClosesIssue("duplicate")).toBe(false);
|
||||
expect(classificationClosesIssue("needs-info")).toBe(false);
|
||||
expect(classificationClosesIssue("uncertain")).toBe(false);
|
||||
expect(classificationClosesIssue("security")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractLinkedIssueNumber,
|
||||
isReviewPendingFromReviews,
|
||||
} from "../pr-reviewer";
|
||||
|
||||
describe("extractLinkedIssueNumber", () => {
|
||||
it("matches 'Fixes #123'", () => {
|
||||
expect(extractLinkedIssueNumber("Fixes #123")).toBe(123);
|
||||
});
|
||||
|
||||
it("matches 'closes #45'", () => {
|
||||
expect(extractLinkedIssueNumber("This PR closes #45 by adding...")).toBe(
|
||||
45,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches 'Resolves #99'", () => {
|
||||
expect(extractLinkedIssueNumber("Summary\n\nResolves #99")).toBe(99);
|
||||
});
|
||||
|
||||
it("matches 'fixed #7'", () => {
|
||||
expect(extractLinkedIssueNumber("fixed #7")).toBe(7);
|
||||
});
|
||||
|
||||
it("prefers the first match when multiple keywords are present", () => {
|
||||
expect(extractLinkedIssueNumber("Fixes #10\n\nAlso closes #20")).toBe(10);
|
||||
});
|
||||
|
||||
it("returns null when no linking keyword is found", () => {
|
||||
expect(extractLinkedIssueNumber("Just a refactor")).toBeNull();
|
||||
expect(
|
||||
extractLinkedIssueNumber("Reference to #50 but no keyword"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty body", () => {
|
||||
expect(extractLinkedIssueNumber("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReviewPendingFromReviews", () => {
|
||||
const HEAD = "abc123";
|
||||
const OLD = "def456";
|
||||
|
||||
it("pending when maintainer has no reviews at all", () => {
|
||||
expect(isReviewPendingFromReviews([], "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("pending when only OTHER reviewers have reviewed the head", () => {
|
||||
const reviews = [{ user_login: "alice", commit_id: HEAD }];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("pending when maintainer reviewed an OLD commit (new commits pushed)", () => {
|
||||
const reviews = [{ user_login: "evan", commit_id: OLD }];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(true);
|
||||
});
|
||||
|
||||
it("not pending when maintainer has reviewed the current head", () => {
|
||||
const reviews = [
|
||||
{ user_login: "evan", commit_id: OLD },
|
||||
{ user_login: "evan", commit_id: HEAD },
|
||||
];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(false);
|
||||
});
|
||||
|
||||
it("not pending if maintainer has at least one review on head (even if also reviewed older commits)", () => {
|
||||
const reviews = [
|
||||
{ user_login: "alice", commit_id: HEAD },
|
||||
{ user_login: "evan", commit_id: HEAD },
|
||||
];
|
||||
expect(isReviewPendingFromReviews(reviews, "evan", HEAD)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user