This commit is contained in:
devin-ai-integration[bot] 2026-10-03 05:12:09 -07:00 • committed by GitHub
commit 6ed7ef36d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 467 additions and 23 deletions

View file

@ -54,5 +54,15 @@
"template": { "color": "E99695", "description": "Required sections of the issue template are missing or empty" },
"version": { "color": "E99695", "description": "No LiteLLM version anywhere in the issue" },
"repro": { "color": "E99695", "description": "A bug with no command, output or screenshot to reproduce it" }
},
"dup": {
"clear": { "color": "0E8A16", "description": "The duplicate check ran and named no earlier issue as the original" }
},
"repro": {
"running": { "color": "1D76DB", "description": "Devin is reproducing this now; the lock that keeps a second run from starting" },
"success": { "color": "0E8A16", "description": "Devin reproduced it; the PR or Linear ticket is linked in a comment" },
"fail": { "color": "E99695", "description": "Devin could not reproduce it with what the issue provides" },
"blocked": { "color": "D93F0B", "description": "Devin needs something only a human can supply before it can reproduce this" },
"skip": { "color": "C5DEF5", "description": "A human opted this issue out of automatic reproduction" }
}
}

View file

@ -13,8 +13,10 @@ on:
- .github/workflows/duplicate_issue_check.yml
- .github/prompts/duplicate-issue-check.md
- .github/prompts/duplicate-issue-check.schema.json
- .github/issue-labels.json
- scripts/flag-duplicate-issue.ts
- scripts/flag-duplicate-issue.test.ts
- scripts/issue-labels.ts
- scripts/auto-close-duplicates.ts
permissions: {}
@ -125,7 +127,9 @@ jobs:
- name: Checkout scripts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: scripts
sparse-checkout: |
.github
scripts
persist-credentials: false
- name: Setup Bun

View file

@ -0,0 +1,85 @@
name: Issue triage dispatch
on:
issues:
types: [labeled, unlabeled]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to run the gate on manually."
required: true
pull_request:
paths:
- .github/workflows/issue_triage_dispatch.yml
- .github/issue-labels.json
- scripts/dispatch-issue-triage.ts
- scripts/dispatch-issue-triage.test.ts
- scripts/flag-duplicate-issue.ts
- scripts/issue-labels.ts
- scripts/auto-close-duplicates.ts
permissions: {}
# Runs for one issue queue instead of cancelling, so two label events land one after the other and the second sees the lock the first added
concurrency:
group: issue-triage-dispatch-${{ github.event.issue.number || github.event.inputs.issue_number || github.run_id }}
cancel-in-progress: false
jobs:
dispatch-issue-triage-tests:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.4.0"
- name: Test the gate
run: bun test scripts/dispatch-issue-triage.test.ts
dispatch-issue-triage:
# Only the labels the gate reads can change its answer; every other label event is dropped here without a checkout
if: >-
github.event_name != 'pull_request'
&& github.repository == 'BerriAI/litellm'
&& (
github.event_name == 'workflow_dispatch'
|| (github.event.action == 'labeled' && (github.event.label.name == 'kind:bug' || github.event.label.name == 'dup:clear'))
|| (github.event.action == 'unlabeled' && (startsWith(github.event.label.name, 'needs:') || startsWith(github.event.label.name, 'repro:') || github.event.label.name == 'potential-duplicate'))
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- name: Checkout scripts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: |
.github
scripts
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# Exact version, never latest: the next step holds an issues: write token
bun-version: "1.4.0"
# The issue is read through the API inside the script, so its text never reaches a shell
- name: Gate the issue and add the repro:running lock when it passes
run: bun run scripts/dispatch-issue-triage.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
DRY_RUN: ${{ vars.TRIAGE_DISPATCH_ENABLED != 'true' }}

View file

@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test";
import type { Comment, GitHubApi } from "./auto-close-duplicates";
import {
DISPATCH_COMMENT,
DISPATCH_MARKER,
RUNNING_LABEL,
dispatchIssue,
dispatchTarget,
openPullsReferencing,
type DispatchVerdict,
type IssueForDispatch,
type TimelineEvent,
} from "./dispatch-issue-triage";
import type { FlagConfig } from "./flag-duplicate-issue";
const ready = (labels: readonly string[] = ["kind:bug", "dup:clear", "lift:small", "domain:caching"], overrides: Partial<IssueForDispatch> = {}): IssueForDispatch => ({
number: 41900,
state: "open",
labels: labels.map((name) => ({ name })),
...overrides,
});
const crossRef = (number: number, state: string, pull = true): TimelineEvent => ({
event: "cross-referenced",
source: { issue: { number, state, ...(pull ? { pull_request: {} } : {}) } },
});
const marker: Comment = {
id: 1,
body: DISPATCH_COMMENT,
created_at: "2026-09-18T00:00:00Z",
user: { type: "Bot", login: "github-actions[bot]" },
};
const config: FlagConfig = { repo: "BerriAI/litellm", issueNumber: 41900, dryRun: false };
const reasonOf = (verdict: DispatchVerdict): string => (verdict.kind === "skip" ? verdict.reason : "");
describe("dispatchTarget", () => {
test("an open bug cleared of duplicates with no needs, no repro state, no marker and no PR is dispatched", () => {
expect(dispatchTarget(ready(), [], [])).toEqual({ kind: "dispatch" });
});
test("both positive signals are required, label absence never counts", () => {
expect(reasonOf(dispatchTarget(ready(["dup:clear"]), [], []))).toBe("missing kind:bug");
expect(reasonOf(dispatchTarget(ready(["kind:bug"]), [], []))).toBe("missing dup:clear");
expect(reasonOf(dispatchTarget(ready(["kind:feature", "dup:clear"]), [], []))).toBe("missing kind:bug");
expect(reasonOf(dispatchTarget(ready([]), [], []))).toBe("missing kind:bug and dup:clear");
});
test("closed issues and pull requests are left alone", () => {
expect(reasonOf(dispatchTarget(ready(undefined, { state: "closed" }), [], []))).toBe("is closed");
expect(reasonOf(dispatchTarget(ready(undefined, { pull_request: {} }), [], []))).toBe("is a pull request");
});
test("any needs:*, any repro:* or a duplicate marker blocks, and every blocker is named", () => {
const base = ["kind:bug", "dup:clear"];
expect(reasonOf(dispatchTarget(ready([...base, "needs:repro"]), [], []))).toBe("carries needs:repro");
expect(reasonOf(dispatchTarget(ready([...base, "needs:version"]), [], []))).toBe("carries needs:version");
expect(reasonOf(dispatchTarget(ready([...base, "repro:running"]), [], []))).toBe("carries repro:running");
expect(reasonOf(dispatchTarget(ready([...base, "repro:skip"]), [], []))).toBe("carries repro:skip");
expect(reasonOf(dispatchTarget(ready([...base, "repro:fail"]), [], []))).toBe("carries repro:fail");
expect(reasonOf(dispatchTarget(ready([...base, "potential-duplicate"]), [], []))).toBe("carries potential-duplicate");
expect(reasonOf(dispatchTarget(ready([...base, "duplicate"]), [], []))).toBe("carries duplicate");
expect(reasonOf(dispatchTarget(ready([...base, "needs:repro", "repro:skip"]), [], []))).toBe("carries needs:repro, repro:skip");
});
test("a marker comment left by an earlier dispatch blocks even after the labels were cleaned up", () => {
expect(reasonOf(dispatchTarget(ready(), [marker], []))).toBe("was already dispatched once");
const unrelated: Comment = { ...marker, body: "I hit this too" };
expect(dispatchTarget(ready(), [unrelated], [])).toEqual({ kind: "dispatch" });
});
test("only the workflow's own marker counts, a commenter pasting it cannot opt the issue out", () => {
const forged: Comment = { ...marker, user: { type: "User", login: "someone" } };
expect(dispatchTarget(ready(), [forged], [])).toEqual({ kind: "dispatch" });
const otherBot: Comment = { ...marker, user: { type: "Bot", login: "some-other-app[bot]" } };
expect(dispatchTarget(ready(), [otherBot], [])).toEqual({ kind: "dispatch" });
});
test("an open pull request that references the issue blocks, a merged one or a plain issue does not", () => {
expect(reasonOf(dispatchTarget(ready(), [], [crossRef(41950, "open")]))).toBe("open pull request #41950 already references it");
expect(reasonOf(dispatchTarget(ready(), [], [crossRef(41950, "open"), crossRef(41960, "open")]))).toBe(
"open pull requests #41950, #41960 already reference it",
);
expect(dispatchTarget(ready(), [], [crossRef(41950, "closed")])).toEqual({ kind: "dispatch" });
expect(dispatchTarget(ready(), [], [crossRef(41950, "open", false)])).toEqual({ kind: "dispatch" });
});
});
describe("openPullsReferencing", () => {
test("keeps open pull requests once each, sorted, and ignores other timeline events", () => {
const timeline: readonly TimelineEvent[] = [
{ event: "labeled" },
crossRef(300, "open"),
crossRef(200, "open"),
crossRef(300, "open"),
crossRef(100, "closed"),
crossRef(50, "open", false),
{ event: "cross-referenced" },
];
expect(openPullsReferencing(timeline)).toEqual([200, 300]);
});
});
describe("dispatchIssue", () => {
function fakeApi(
issue: IssueForDispatch = ready(),
comments: readonly Comment[] = [],
timeline: readonly TimelineEvent[] = [],
): { readonly api: GitHubApi; readonly writes: string[] } {
const writes: string[] = [];
const api: GitHubApi = {
request: async <T>(method: string, path: string, body?: object): Promise<T> => {
if (method !== "GET") {
writes.push(`${method} ${path} ${JSON.stringify(body)}`);
return {} as T;
}
if (path === "/repos/BerriAI/litellm/issues/41900") {
return issue as T;
}
if (path.startsWith("/repos/BerriAI/litellm/issues/41900/comments")) {
return comments as T;
}
if (path.startsWith("/repos/BerriAI/litellm/issues/41900/timeline")) {
return timeline as T;
}
throw new Error(`unexpected GET ${path}`);
},
};
return { api, writes };
}
test("a real run adds the lock label first, then the marker comment", async () => {
const { api, writes } = fakeApi();
expect(await dispatchIssue(api, config)).toEqual({ kind: "dispatch" });
expect(writes).toEqual([
`POST /repos/BerriAI/litellm/issues/41900/labels {"labels":["${RUNNING_LABEL}"]}`,
`POST /repos/BerriAI/litellm/issues/41900/comments ${JSON.stringify({ body: DISPATCH_COMMENT })}`,
]);
expect(DISPATCH_COMMENT.startsWith(DISPATCH_MARKER)).toBe(true);
});
test("a dry run decides the same way and writes nothing", async () => {
const { api, writes } = fakeApi();
expect(await dispatchIssue(api, { ...config, dryRun: true })).toEqual({ kind: "dispatch" });
expect(writes).toEqual([]);
});
test("a blocked issue is never written to, whatever blocked it", async () => {
const labelled = fakeApi(ready(["kind:bug", "dup:clear", "repro:running"]));
expect((await dispatchIssue(labelled.api, config)).kind).toBe("skip");
expect(labelled.writes).toEqual([]);
const marked = fakeApi(ready(), [marker]);
expect((await dispatchIssue(marked.api, config)).kind).toBe("skip");
expect(marked.writes).toEqual([]);
const referenced = fakeApi(ready(), [], [crossRef(41950, "open")]);
expect((await dispatchIssue(referenced.api, config)).kind).toBe("skip");
expect(referenced.writes).toEqual([]);
});
test("the second run after a successful dispatch is a no-op because the label it just added blocks it", async () => {
const first = fakeApi();
await dispatchIssue(first.api, config);
const second = fakeApi(ready(["kind:bug", "dup:clear", RUNNING_LABEL]), [marker]);
expect(reasonOf(await dispatchIssue(second.api, config))).toBe(`carries ${RUNNING_LABEL}`);
expect(second.writes).toEqual([]);
});
test("the reporter-facing comment stays within the 15 to 25 word limit", () => {
const words = DISPATCH_COMMENT.split("\n").slice(1).join(" ").trim().split(/\s+/);
expect(words.length).toBeGreaterThanOrEqual(15);
expect(words.length).toBeLessThanOrEqual(25);
});
});

View file

@ -0,0 +1,114 @@
#!/usr/bin/env bun
import { FLAG_LABEL, githubApi, listAll, type Comment, type GitHubApi } from "./auto-close-duplicates";
import { CLEAR_LABEL, readConfig, WORKFLOW_LOGIN, type FlagConfig as DispatchConfig } from "./flag-duplicate-issue";
import { labelName } from "./issue-labels";
declare const process: { readonly env: Readonly<Record<string, string | undefined>> };
export interface IssueForDispatch {
readonly number: number;
readonly state: string;
readonly labels: readonly { readonly name: string }[];
readonly pull_request?: unknown;
}
export interface TimelineSource {
readonly number: number;
readonly state: string;
readonly pull_request?: unknown;
}
export interface TimelineEvent {
readonly event: string;
readonly source?: { readonly issue?: TimelineSource };
}
export type DispatchVerdict =
| { readonly kind: "dispatch" }
| { readonly kind: "skip"; readonly reason: string };
export const BUG_LABEL = labelName("kind", "bug");
export const RUNNING_LABEL = labelName("repro", "running");
export const DISPATCH_MARKER = "<!-- litellm:repro-dispatched -->";
export const DISPATCH_COMMENT = [
DISPATCH_MARKER,
"Devin is going to try to reproduce this. A PR or a Linear ticket follows if it does. Add repro:skip to opt out",
].join("\n");
const BLOCKING_LABELS: readonly string[] = [FLAG_LABEL, "duplicate"];
const BLOCKING_PREFIXES: readonly string[] = ["needs:", "repro:"];
const skip = (reason: string): DispatchVerdict => ({ kind: "skip", reason });
export function openPullsReferencing(timeline: readonly TimelineEvent[]): readonly number[] {
const numbers = timeline
.flatMap((event) => (event.event === "cross-referenced" && event.source?.issue !== undefined ? [event.source.issue] : []))
.filter((source) => source.pull_request !== undefined && source.state === "open")
.map((source) => source.number);
return [...new Set(numbers)].sort((a, b) => a - b);
}
export function dispatchTarget(
issue: IssueForDispatch,
comments: readonly Comment[],
timeline: readonly TimelineEvent[],
): DispatchVerdict {
if (issue.pull_request !== undefined) {
return skip("is a pull request");
}
if (issue.state !== "open") {
return skip(`is ${issue.state}`);
}
const labels = issue.labels.map((label) => label.name);
const missing = [BUG_LABEL, CLEAR_LABEL].filter((label) => !labels.includes(label));
if (missing.length > 0) {
return skip(`missing ${missing.join(" and ")}`);
}
const blocking = labels.filter(
(label) => BLOCKING_LABELS.includes(label) || BLOCKING_PREFIXES.some((prefix) => label.startsWith(prefix)),
);
if (blocking.length > 0) {
return skip(`carries ${blocking.join(", ")}`);
}
if (comments.some((comment) => comment.user.login === WORKFLOW_LOGIN && comment.body.includes(DISPATCH_MARKER))) {
return skip("was already dispatched once");
}
const pulls = openPullsReferencing(timeline);
if (pulls.length === 1) {
return skip(`open pull request #${pulls[0]} already references it`);
}
if (pulls.length > 1) {
return skip(`open pull requests ${pulls.map((n) => `#${n}`).join(", ")} already reference it`);
}
return { kind: "dispatch" };
}
export async function dispatchIssue(api: GitHubApi, config: DispatchConfig): Promise<DispatchVerdict> {
const issuePath = `/repos/${config.repo}/issues/${config.issueNumber}`;
const issue = await api.request<IssueForDispatch>("GET", issuePath);
const comments = await listAll<Comment>(api, `${issuePath}/comments`);
const timeline = await listAll<TimelineEvent>(api, `${issuePath}/timeline`);
const verdict = dispatchTarget(issue, comments, timeline);
if (verdict.kind === "skip" || config.dryRun) {
return verdict;
}
await api.request("POST", `${issuePath}/labels`, { labels: [RUNNING_LABEL] });
await api.request("POST", `${issuePath}/comments`, { body: DISPATCH_COMMENT });
return verdict;
}
function describe(config: DispatchConfig, verdict: DispatchVerdict): string {
if (verdict.kind === "skip") {
return `#${config.issueNumber}: skipped, ${verdict.reason}`;
}
if (config.dryRun) {
return `#${config.issueNumber}: DRY RUN, set the TRIAGE_DISPATCH_ENABLED repo variable to true to add ${RUNNING_LABEL} and comment`;
}
return `#${config.issueNumber}: added ${RUNNING_LABEL} and commented, Devin's automation takes it from here`;
}
if (import.meta.main) {
const { token, ...config } = readConfig(process.env);
const verdict = await dispatchIssue(githubApi(token), config);
console.log(describe(config, verdict));
}

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { candidateNumbers, duplicateTarget, type Comment, type GitHubApi, type Issue } from "./auto-close-duplicates";
import {
CLEAR_LABEL,
MIN_CONFIDENCE,
flagIssue,
flagTarget,
@ -51,15 +52,15 @@ describe("parseVerdict", () => {
});
describe("flagTarget", () => {
test("flags at the gate and not one hundredth below it", () => {
test("flags at the gate and clears one hundredth below it", () => {
expect(flagTarget(verdict({ confidence: MIN_CONFIDENCE }), 35)).toEqual({ kind: "target", original: 10 });
expect(flagTarget(verdict({ confidence: 0.94 }), 35).kind).toBe("skip");
expect(flagTarget(verdict({ confidence: 0.94 }), 35).kind).toBe("clear");
});
test("never flags nothing, itself, or a newer issue", () => {
expect(flagTarget(verdict({ duplicate_of: null }), 35).kind).toBe("skip");
expect(flagTarget(verdict({ duplicate_of: 35 }), 35).kind).toBe("skip");
expect(flagTarget(verdict({ duplicate_of: 36 }), 35).kind).toBe("skip");
test("nothing, itself, or a newer issue all count as a clear result", () => {
expect(flagTarget(verdict({ duplicate_of: null }), 35).kind).toBe("clear");
expect(flagTarget(verdict({ duplicate_of: 35 }), 35).kind).toBe("clear");
expect(flagTarget(verdict({ duplicate_of: 36 }), 35).kind).toBe("clear");
});
});
@ -163,12 +164,27 @@ describe("flagIssue", () => {
expect(writes).toEqual([]);
});
test("a verdict below the gate never touches the API", async () => {
test("a verdict naming no duplicate adds dup:clear and nothing else", async () => {
const { api, writes } = fakeApi();
expect((await flagIssue(api, config, verdict({ confidence: 0.9 }))).kind).toBe("skip");
expect(await flagIssue(api, config, verdict({ duplicate_of: null }))).toEqual({ kind: "clear", reason: "no duplicate named" });
expect(writes).toEqual([`POST /repos/BerriAI/litellm/issues/35/labels {"labels":["${CLEAR_LABEL}"]}`]);
});
test("a verdict below the gate clears the issue too, and a dry run only reports it", async () => {
const real = fakeApi();
expect((await flagIssue(real.api, config, verdict({ confidence: 0.9 }))).kind).toBe("clear");
expect(real.writes).toEqual([`POST /repos/BerriAI/litellm/issues/35/labels {"labels":["${CLEAR_LABEL}"]}`]);
const { api, writes } = fakeApi();
expect((await flagIssue(api, { ...config, dryRun: true }, verdict({ confidence: 0.9 }))).kind).toBe("clear");
expect(writes).toEqual([]);
});
test("a garbled verdict is a skip, never a clear", () => {
expect(parseVerdict("not json").kind).toBe("skip");
expect(CLEAR_LABEL).toBe("dup:clear");
});
test("an issue that already carries a notice is not flagged twice", async () => {
const existing: Comment = {
id: 1,
@ -179,6 +195,25 @@ describe("flagIssue", () => {
const { api, writes } = fakeApi(undefined, [existing]);
expect(await flagIssue(api, config, verdict())).toEqual({ kind: "skip", reason: "already carries a duplicate notice" });
expect(writes).toEqual([]);
const rerun = fakeApi(undefined, [existing]);
expect(await flagIssue(rerun.api, config, verdict({ duplicate_of: null }))).toEqual({
kind: "skip",
reason: "already carries a duplicate notice",
});
expect(rerun.writes).toEqual([]);
});
test("a notice marker pasted by a commenter is not a notice", async () => {
const pasted: Comment = {
id: 2,
body: "<!-- litellm:potential-duplicate candidates=10, --> looks like #10 to me",
created_at: "2026-09-10T00:00:00Z",
user: { type: "User", login: "someone" },
};
const { api, writes } = fakeApi(undefined, [pasted]);
expect((await flagIssue(api, config, verdict({ duplicate_of: null }))).kind).toBe("clear");
expect(writes).toEqual([`POST /repos/BerriAI/litellm/issues/35/labels {"labels":["${CLEAR_LABEL}"]}`]);
});
test("a failed comment leaves no marker, so the rerun finishes the job", async () => {

View file

@ -10,6 +10,7 @@ import {
type GitHubApi,
type Issue,
} from "./auto-close-duplicates";
import { labelName } from "./issue-labels";
declare const process: { readonly env: Readonly<Record<string, string | undefined>> };
@ -31,16 +32,20 @@ export type ParsedVerdict =
export type FlagTarget =
| { readonly kind: "target"; readonly original: number }
| { readonly kind: "skip"; readonly reason: string };
| { readonly kind: "clear"; readonly reason: string };
export type FlagVerdict =
| { readonly kind: "flagged"; readonly original: number; readonly body: string }
| { readonly kind: "clear"; readonly reason: string }
| { readonly kind: "skip"; readonly reason: string };
export const MIN_CONFIDENCE = 0.95;
export const NOTICE_MARKER_PREFIX = "<!-- litellm:potential-duplicate candidates=";
export const WORKFLOW_LOGIN = "github-actions[bot]";
export const CLEAR_LABEL = labelName("dup", "clear");
const skip = (reason: string): { readonly kind: "skip"; readonly reason: string } => ({ kind: "skip", reason });
const clear = (reason: string): { readonly kind: "clear"; readonly reason: string } => ({ kind: "clear", reason });
const parseJson = (raw: string): unknown => {
try {
@ -70,13 +75,13 @@ export function parseVerdict(raw: string): ParsedVerdict {
export function flagTarget(verdict: Verdict, issueNumber: number): FlagTarget {
if (verdict.duplicate_of === null) {
return skip("no duplicate named");
return clear("no duplicate named");
}
if (verdict.confidence < MIN_CONFIDENCE) {
return skip(`confidence ${verdict.confidence} is below ${MIN_CONFIDENCE}`);
return clear(`confidence ${verdict.confidence} is below ${MIN_CONFIDENCE}`);
}
if (verdict.duplicate_of >= issueNumber) {
return skip(`#${verdict.duplicate_of} is not older than #${issueNumber}`);
return clear(`#${verdict.duplicate_of} is not older than #${issueNumber}`);
}
return { kind: "target", original: verdict.duplicate_of };
}
@ -98,14 +103,17 @@ export function noticeBody(issue: Issue, prior: Issue, evidence: string): string
export async function flagIssue(api: GitHubApi, config: FlagConfig, verdict: Verdict): Promise<FlagVerdict> {
const target = flagTarget(verdict, config.issueNumber);
if (target.kind === "skip") {
return target;
}
const issuePath = `/repos/${config.repo}/issues/${config.issueNumber}`;
const comments = await listAll<Comment>(api, `${issuePath}/comments`);
if (comments.some((comment) => comment.body.includes(NOTICE_MARKER_PREFIX))) {
if (comments.some((comment) => comment.user.login === WORKFLOW_LOGIN && comment.body.includes(NOTICE_MARKER_PREFIX))) {
return skip("already carries a duplicate notice");
}
if (target.kind === "clear") {
if (!config.dryRun) {
await api.request("POST", `${issuePath}/labels`, { labels: [CLEAR_LABEL] });
}
return target;
}
const prior = await api.request<Issue>("GET", `/repos/${config.repo}/issues/${target.original}`);
if (prior.pull_request !== undefined) {
return skip(`#${target.original} is a pull request`);
@ -136,6 +144,10 @@ function describe(config: FlagConfig, verdict: FlagVerdict): string {
if (verdict.kind === "skip") {
return `#${config.issueNumber}: skipped, ${verdict.reason}`;
}
if (verdict.kind === "clear") {
const action = config.dryRun ? `DRY RUN, would add ${CLEAR_LABEL}` : `added ${CLEAR_LABEL}`;
return `#${config.issueNumber}: ${action}, ${verdict.reason}`;
}
if (config.dryRun) {
return `#${config.issueNumber}: DRY RUN, set the DUPLICATE_CHECK_ENABLED repo variable to true to post this:\n\n${verdict.body}`;
}

View file

@ -2,13 +2,15 @@ import manifest from "../.github/issue-labels.json";
export const NAMESPACES = ["domain", "provider", "kind", "priority", "lift", "needs"] as const;
export type Namespace = (typeof NAMESPACES)[number];
export const LIFECYCLE_NAMESPACES = ["dup", "repro"] as const;
export type LifecycleNamespace = (typeof LIFECYCLE_NAMESPACES)[number];
export interface LabelSpec {
readonly color: string;
readonly description: string;
}
export type Manifest = Readonly<Record<Namespace, Readonly<Record<string, LabelSpec>>>>;
export type Manifest = Readonly<Record<Namespace | LifecycleNamespace, Readonly<Record<string, LabelSpec>>>>;
export interface ManifestLabel extends LabelSpec {
readonly name: string;
@ -16,7 +18,7 @@ export interface ManifestLabel extends LabelSpec {
export const MANIFEST: Manifest = manifest;
export function labelName(namespace: Namespace, value: string): string {
export function labelName(namespace: Namespace | LifecycleNamespace, value: string): string {
return `${namespace}:${value}`;
}
@ -26,7 +28,7 @@ export function namespaceOf(label: string): Namespace | undefined {
}
export function manifestLabels(source: Manifest): readonly ManifestLabel[] {
return NAMESPACES.flatMap((namespace) =>
return [...NAMESPACES, ...LIFECYCLE_NAMESPACES].flatMap((namespace) =>
Object.entries(source[namespace]).map(([value, spec]) => ({ name: labelName(namespace, value), ...spec })),
);
}

View file

@ -11,6 +11,8 @@ const small: Manifest = {
priority: { p0: { color: "B60205", description: "Bleeding" } },
lift: {},
needs: { template: { color: "E99695", description: "Template sections missing" } },
dup: {},
repro: { skip: { color: "C5DEF5", description: "Opted out" } },
};
describe("syncPlan", () => {
@ -24,6 +26,7 @@ describe("syncPlan", () => {
"unchanged domain:caching",
"update priority:p0",
"create needs:template",
"create repro:skip",
]);
});
@ -32,8 +35,8 @@ describe("syncPlan", () => {
expect(syncPlan(existing, small)[0]?.kind).toBe("update");
});
test("the real manifest is 44 labels across six namespaces", () => {
expect(manifestLabels(MANIFEST)).toHaveLength(44);
test("the real manifest is 50 labels across eight namespaces", () => {
expect(manifestLabels(MANIFEST)).toHaveLength(50);
expect(syncPlan([], MANIFEST).every((action) => action.kind === "create")).toBe(true);
});
});
@ -63,13 +66,14 @@ describe("syncLabels", () => {
'POST /repos/BerriAI/litellm/labels {"name":"domain:caching","color":"1C6E5B","description":"Response cache"}',
'PATCH /repos/BerriAI/litellm/labels/priority%3Ap0 {"color":"B60205","description":"Bleeding"}',
'POST /repos/BerriAI/litellm/labels {"name":"needs:template","color":"E99695","description":"Template sections missing"}',
'POST /repos/BerriAI/litellm/labels {"name":"repro:skip","color":"C5DEF5","description":"Opted out"}',
]);
});
test("a dry run returns the plan and writes nothing", async () => {
const { api, writes } = fakeApi([]);
const plan = await syncLabels(api, { repo: "BerriAI/litellm", dryRun: true }, small);
expect(plan.map((action) => action.kind)).toEqual(["create", "create", "create"]);
expect(plan.map((action) => action.kind)).toEqual(["create", "create", "create", "create"]);
expect(writes).toEqual([]);
});
});