fix(workflow): prevent circular duplicate closure in auto-close script

- Skip closing an issue as duplicate of another that is already closed
  (avoids circular closure when bot and human close in opposite order).
- Skip when duplicate target is self (same issue number).
- Extract testable helpers: isDupeComment, isDupeCommentOldEnough,
  authorDisagreedWithDupe, getLastDupeComment, decideAutoClose.
- Add 23 unit tests (Bun) and run them in CI before auto-close step.
- Add scripts/AUTO_CLOSE_DUPLICATES_CROSS_VERIFY.md for impact summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mishrapravin114
2026-01-29 00:22:11 +05:30
parent c9f3de1af6
commit 05dde7414f
5 changed files with 404 additions and 29 deletions
@@ -22,6 +22,9 @@ jobs:
with:
bun-version: latest
- name: Run auto-close-duplicates tests
run: bun test scripts/auto-close-duplicates
- name: Auto-close duplicate issues
run: bun run scripts/auto-close-duplicates.ts
env:
+2 -1
View File
@@ -9,7 +9,8 @@
},
"license": "Apache-2.0",
"scripts": {
"setup": "echo '⚠️ This npm setup is for the archived web application. For agent development, use: ./scripts/setup-python.sh' && bash scripts/setup.sh"
"setup": "echo '⚠️ This npm setup is for the archived web application. For agent development, use: ./scripts/setup-python.sh' && bash scripts/setup.sh",
"test:duplicates": "bun test scripts/auto-close-duplicates"
},
"devDependencies": {
"@types/node": "^20.10.0",
@@ -0,0 +1,52 @@
# Cross-verification: auto-close-duplicates changes
## 1. Files changed (this PR / session)
| File | Change |
|------|--------|
| `scripts/auto-close-duplicates.ts` | Circular-dup + self-ref prevention; extracted helpers (isDupeComment, isDupeCommentOldEnough, authorDisagreedWithDupe, getLastDupeComment, decideAutoClose); `if (import.meta.main)` guard |
| `scripts/auto-close-duplicates.test.ts` | New: 23 unit tests for all exported helpers |
| `package.json` | Added `"test:duplicates": "bun test scripts/auto-close-duplicates"` |
| `.github/workflows/auto-close-duplicates.yml` | Added step "Run auto-close-duplicates tests" before auto-close step |
**Not changed:** `core/`, `tools/`, main CI (`.github/workflows/ci.yml`), triage workflow logic (only the script that consumes its comments).
---
## 2. Dependencies / who uses what
- **Only consumer of the script:** `.github/workflows/auto-close-duplicates.yml` runs `bun run scripts/auto-close-duplicates.ts`. No other code imports or runs it.
- **Only consumer of the scripts exports:** `scripts/auto-close-duplicates.test.ts` imports the exported helpers for unit tests.
- **Triage workflow contract:** `.github/workflows/claude-issue-triage.yml` instructs the bot to post: `"Found a possible duplicate of #<issue_number>: ..."`. The script still recognises this via `isDupeComment` (body includes `"possible duplicate"` and `comment.user.type === "Bot"`). **No change to that contract.**
---
## 3. Backward compatibility
- **Happy path unchanged:** Issue A has a bot comment “possible duplicate of #B”, B is open, comment &gt; 12h old, no author thumbs-down → script still closes A as duplicate of B.
- **New safeguards only:** (1) If B is already closed → skip (no close). (2) If comment says “duplicate of #A” on issue A (self) → skip. (3) If target fetch fails or returns non-`open` state → skip.
- **Comment format:** Still `"possible duplicate"` + Bot; `extractDuplicateIssueNumber` still supports `#N` and GitHub issue URL. No change required in triage prompt.
---
## 4. What was not touched
- **Core / tools / agent_builder:** No edits to `core/`, `tools/`, or `agent_builder_server.py`. Grep hits for `agent_builder` / `SESSIONS_DIR` are in other docs or local issue/notes, not in this script.
- **Main CI:** `ci.yml` only runs Python (ruff + pytest). It does **not** run the TS script or `bun test`. So these changes do not affect main CI.
- **Triage workflow:** Only the **comment text** is consumed by the script. We did not change the triage YAML or the bots instructions; we only changed how the auto-close script interprets comments and when it skips closing.
---
## 5. Verification performed
- **Unit tests:** `bun test scripts/auto-close-duplicates` → 23 tests pass.
- **Script entry:** `bun run scripts/auto-close-duplicates.ts` with fake env runs and fails at first API call (401), so the main path still runs and `import.meta.main` guard works.
- **No other imports:** Only `scripts/auto-close-duplicates.test.ts` imports from `auto-close-duplicates.ts`.
---
## 6. Conclusion
- **Impact:** Limited to the auto-close-duplicates workflow and its tests. No impact on core, tools, main CI, or triage prompt text.
- **Behavior:** Same for the normal case; only adds skip conditions for circular duplicate, self-reference, and closed/unreachable target.
- **Contract:** Triage comment format and 12h + author-reaction behaviour unchanged; script remains compatible with existing triage.
+261
View File
@@ -0,0 +1,261 @@
/**
* Tests for auto-close-duplicates script: comment filter, 12h check,
* author reaction, extractDuplicateIssueNumber, and decideAutoClose
* (circular-dup and self-ref prevention).
*/
import { describe, expect, test } from "bun:test";
import {
authorDisagreedWithDupe,
decideAutoClose,
extractDuplicateIssueNumber,
getLastDupeComment,
isDupeComment,
isDupeCommentOldEnough,
type GitHubComment,
type GitHubIssue,
type GitHubReaction,
} from "./auto-close-duplicates";
describe("extractDuplicateIssueNumber", () => {
test("extracts #123 format", () => {
expect(
extractDuplicateIssueNumber("Found a possible duplicate of #1275: ...")
).toBe(1275);
expect(extractDuplicateIssueNumber("Duplicate of #1")).toBe(1);
expect(extractDuplicateIssueNumber("See #1000")).toBe(1000);
});
test("extracts first #N when multiple present", () => {
expect(
extractDuplicateIssueNumber("Duplicate of #1000 and also #1275")
).toBe(1000);
});
test("extracts GitHub issue URL format", () => {
expect(
extractDuplicateIssueNumber(
"Duplicate of https://github.com/adenhq/hive/issues/42"
)
).toBe(42);
});
test("returns null when no issue number", () => {
expect(extractDuplicateIssueNumber("No number here")).toBe(null);
expect(extractDuplicateIssueNumber("")).toBe(null);
});
});
describe("isDupeComment", () => {
test("true when body has 'possible duplicate' and user is Bot", () => {
expect(
isDupeComment({
id: 1,
body: "Found a possible duplicate of #1000: same bug",
created_at: "",
user: { type: "Bot", id: 2 },
})
).toBe(true);
expect(
isDupeComment({
id: 1,
body: "Possible duplicate of #1275",
created_at: "",
user: { type: "Bot", id: 2 },
})
).toBe(true);
});
test("false when body lacks 'possible duplicate'", () => {
expect(
isDupeComment({
id: 1,
body: "Not a duplicate",
created_at: "",
user: { type: "Bot", id: 2 },
})
).toBe(false);
});
test("false when user is not Bot", () => {
expect(
isDupeComment({
id: 1,
body: "Found a possible duplicate of #1000",
created_at: "",
user: { type: "User", id: 2 },
})
).toBe(false);
});
});
describe("isDupeCommentOldEnough", () => {
test("true when comment date is before twelveHoursAgo", () => {
const twelveHoursAgo = new Date("2025-01-28T12:00:00Z");
const oldComment = new Date("2025-01-28T00:00:00Z");
expect(isDupeCommentOldEnough(oldComment, twelveHoursAgo)).toBe(true);
});
test("true when comment date equals twelveHoursAgo", () => {
const twelveHoursAgo = new Date("2025-01-28T12:00:00Z");
expect(isDupeCommentOldEnough(twelveHoursAgo, twelveHoursAgo)).toBe(true);
});
test("false when comment is after twelveHoursAgo (too recent)", () => {
const twelveHoursAgo = new Date("2025-01-28T12:00:00Z");
const recentComment = new Date("2025-01-28T18:00:00Z");
expect(isDupeCommentOldEnough(recentComment, twelveHoursAgo)).toBe(false);
});
});
describe("authorDisagreedWithDupe", () => {
test("true when issue author gave thumbs down", () => {
const issue = { number: 1275, title: "", state: "open", user: { id: 42 }, created_at: "" };
const reactions: GitHubReaction[] = [
{ user: { id: 42 }, content: "-1" },
];
expect(authorDisagreedWithDupe(reactions, issue)).toBe(true);
});
test("false when only other users reacted", () => {
const issue = { number: 1275, title: "", state: "open", user: { id: 42 }, created_at: "" };
const reactions: GitHubReaction[] = [
{ user: { id: 99 }, content: "-1" },
{ user: { id: 1 }, content: "+1" },
];
expect(authorDisagreedWithDupe(reactions, issue)).toBe(false);
});
test("false when author gave +1 or other reaction", () => {
const issue = { number: 1275, title: "", state: "open", user: { id: 42 }, created_at: "" };
expect(authorDisagreedWithDupe([{ user: { id: 42 }, content: "+1" }], issue)).toBe(false);
expect(authorDisagreedWithDupe([{ user: { id: 42 }, content: "eyes" }], issue)).toBe(false);
});
});
describe("getLastDupeComment", () => {
test("returns null when no dupe comments", () => {
expect(
getLastDupeComment([
{ id: 1, body: "Not a duplicate", created_at: "", user: { type: "User", id: 1 } },
])
).toBe(null);
});
test("returns the only dupe comment when one exists", () => {
const c: GitHubComment = {
id: 1,
body: "Found a possible duplicate of #1000",
created_at: "",
user: { type: "Bot", id: 2 },
};
expect(getLastDupeComment([c])).toBe(c);
});
test("returns the last dupe comment when multiple exist", () => {
const c1: GitHubComment = {
id: 1,
body: "Found a possible duplicate of #1000",
created_at: "",
user: { type: "Bot", id: 2 },
};
const c2: GitHubComment = {
id: 2,
body: "Found a possible duplicate of #1275",
created_at: "",
user: { type: "Bot", id: 2 },
};
const other: GitHubComment = {
id: 3,
body: "Some other comment",
created_at: "",
user: { type: "User", id: 3 },
};
expect(getLastDupeComment([other, c1, c2])).toBe(c2);
});
});
function issue(num: number, state = "open"): GitHubIssue {
return {
number: num,
title: `Issue ${num}`,
state,
user: { id: 1 },
created_at: new Date().toISOString(),
};
}
function comment(body: string): GitHubComment {
return {
id: 1,
body,
created_at: new Date().toISOString(),
user: { type: "Bot", id: 2 },
};
}
describe("decideAutoClose", () => {
test("returns null when comment has no extractable issue number", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Possible duplicate of something else"),
async () => ({ state: "open" })
);
expect(result).toBe(null);
});
test("returns null when duplicate target is self (same issue number)", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1275: same issue"),
async () => ({ state: "open" })
);
expect(result).toBe(null);
});
test("returns null when target issue is closed (avoids circular closure)", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1000"),
async (num) => (num === 1000 ? { state: "closed" } : { state: "open" })
);
expect(result).toBe(null);
});
test("returns null when getTargetIssue returns null", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1000"),
async () => null
);
expect(result).toBe(null);
});
test("returns null when getTargetIssue throws", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1000"),
async () => {
throw new Error("API error");
}
);
expect(result).toBe(null);
});
test("returns duplicateOf number when target is open (should close)", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1000: same bug"),
async (num) => (num === 1000 ? { state: "open" } : { state: "closed" })
);
expect(result).toBe(1000);
});
test("returns null when target state is not exactly 'open' (e.g. uppercase)", async () => {
const result = await decideAutoClose(
issue(1275),
comment("Found a possible duplicate of #1000"),
async () => ({ state: "OPEN" } as { state: string })
);
expect(result).toBe(null);
});
});
+85 -27
View File
@@ -6,21 +6,22 @@ declare global {
};
}
interface GitHubIssue {
export interface GitHubIssue {
number: number;
title: string;
state: string;
user: { id: number };
created_at: string;
}
interface GitHubComment {
export interface GitHubComment {
id: number;
body: string;
created_at: string;
user: { type: string; id: number };
}
interface GitHubReaction {
export interface GitHubReaction {
user: { id: number };
content: string;
}
@@ -57,7 +58,41 @@ async function githubRequest<T>(
return response.json();
}
function extractDuplicateIssueNumber(commentBody: string): number | null {
/** True if comment is a bot "possible duplicate" detection (used for filtering). */
export function isDupeComment(comment: GitHubComment): boolean {
const bodyLower = comment.body.toLowerCase();
return (
bodyLower.includes("possible duplicate") && comment.user.type === "Bot"
);
}
/** True if the duplicate comment is old enough to auto-close (>= 12h). */
export function isDupeCommentOldEnough(
dupeCommentDate: Date,
twelveHoursAgo: Date
): boolean {
return dupeCommentDate <= twelveHoursAgo;
}
/** True if the issue author reacted with thumbs down to the duplicate comment. */
export function authorDisagreedWithDupe(
reactions: GitHubReaction[],
issue: GitHubIssue
): boolean {
return reactions.some(
(r) => r.user.id === issue.user.id && r.content === "-1"
);
}
/** Returns the most recent duplicate-detection comment, or null if none. */
export function getLastDupeComment(
comments: GitHubComment[]
): GitHubComment | null {
const dupeComments = comments.filter(isDupeComment);
return dupeComments.length > 0 ? dupeComments[dupeComments.length - 1]! : null;
}
export function extractDuplicateIssueNumber(commentBody: string): number | null {
// Try to match #123 format first
let match = commentBody.match(/#(\d+)/);
if (match) {
@@ -73,6 +108,30 @@ function extractDuplicateIssueNumber(commentBody: string): number | null {
return null;
}
/**
* Decides whether to auto-close this issue as duplicate of another.
* Returns the target issue number to close as duplicate of, or null to skip.
* Used by the main loop and by tests.
*/
export async function decideAutoClose(
issue: GitHubIssue,
lastDupeComment: GitHubComment,
getTargetIssue: (issueNumber: number) => Promise<{ state: string } | null>
): Promise<number | null> {
const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
if (duplicateIssueNumber === null) return null;
if (duplicateIssueNumber === issue.number) return null;
try {
const targetIssue = await getTargetIssue(duplicateIssueNumber);
if (!targetIssue || targetIssue.state !== "open") return null;
return duplicateIssueNumber;
} catch {
return null;
}
}
async function closeIssueAsDuplicate(
owner: string,
repo: string,
@@ -173,25 +232,18 @@ async function autoCloseDuplicates(): Promise<void> {
`[DEBUG] Issue #${issue.number} has ${comments.length} comments`
);
const dupeComments = comments.filter((comment) => {
const bodyLower = comment.body.toLowerCase();
return (
bodyLower.includes("possible duplicate") &&
comment.user.type === "Bot"
);
});
const lastDupeComment = getLastDupeComment(comments);
const dupeCount = comments.filter(isDupeComment).length;
console.log(
`[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`
`[DEBUG] Issue #${issue.number} has ${dupeCount} duplicate detection comments`
);
if (dupeComments.length === 0) {
if (lastDupeComment === null) {
console.log(
`[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`
);
continue;
}
const lastDupeComment = dupeComments[dupeComments.length - 1];
const dupeCommentDate = new Date(lastDupeComment.created_at);
console.log(
`[DEBUG] Issue #${
@@ -199,7 +251,7 @@ async function autoCloseDuplicates(): Promise<void> {
} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`
);
if (dupeCommentDate > twelveHoursAgo) {
if (!isDupeCommentOldEnough(dupeCommentDate, twelveHoursAgo)) {
console.log(
`[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`
);
@@ -224,10 +276,7 @@ async function autoCloseDuplicates(): Promise<void> {
`[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`
);
const authorThumbsDown = reactions.some(
(reaction) =>
reaction.user.id === issue.user.id && reaction.content === "-1"
);
const authorThumbsDown = authorDisagreedWithDupe(reactions, issue);
console.log(
`[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`
);
@@ -239,12 +288,19 @@ async function autoCloseDuplicates(): Promise<void> {
continue;
}
const duplicateIssueNumber = extractDuplicateIssueNumber(
lastDupeComment.body
const duplicateOf = await decideAutoClose(
issue,
lastDupeComment,
(issueNumber) =>
githubRequest<GitHubIssue>(
`/repos/${owner}/${repo}/issues/${issueNumber}`,
token
).then((i) => ({ state: i.state }))
);
if (!duplicateIssueNumber) {
if (duplicateOf === null) {
console.log(
`[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`
`[DEBUG] Issue #${issue.number} - skipping (invalid/self/closed target or fetch error)`
);
continue;
}
@@ -254,17 +310,17 @@ async function autoCloseDuplicates(): Promise<void> {
try {
console.log(
`[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`
`[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateOf}: ${issueUrl}`
);
await closeIssueAsDuplicate(
owner,
repo,
issue.number,
duplicateIssueNumber,
duplicateOf,
token
);
console.log(
`[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`
`[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateOf}`
);
} catch (error) {
console.error(
@@ -278,6 +334,8 @@ async function autoCloseDuplicates(): Promise<void> {
);
}
if (import.meta.main) {
autoCloseDuplicates().catch(console.error);
}
export {};