Merge upstream/main and resolve setup-python.sh conflict

Resolved conflict in scripts/setup-python.sh by keeping upstream's
improved formatting with color codes and ${PYTHON_CMD} variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shivam Sharma
2026-01-29 06:35:48 +05:30
5 changed files with 414 additions and 51 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",
+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);
});
});
+86 -28
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> {
);
}
autoCloseDuplicates().catch(console.error);
if (import.meta.main) {
autoCloseDuplicates().catch(console.error);
}
export {};
+62 -22
View File
@@ -25,16 +25,48 @@ echo " Aden Agent Framework - Python Setup"
echo "=================================================="
echo ""
# Check for Python
if ! command -v python &> /dev/null && ! command -v python3 &> /dev/null; then
# Check for Python, honoring $PYTHON even if it's not on PATH
if [ -n "$PYTHON" ]; then
if [ ! -x "$PYTHON" ] && ! command -v "$PYTHON" &> /dev/null; then
echo -e "${RED}Error: PYTHON is set to '$PYTHON' but it was not found or not executable.${NC}"
echo "Example: PYTHON=/opt/python3.12/bin/python3.12 ./scripts/setup-python.sh"
exit 1
fi
elif ! command -v python &> /dev/null \
&& ! command -v python3 &> /dev/null \
&& ! command -v python3.11 &> /dev/null \
&& ! command -v python3.12 &> /dev/null; then
echo -e "${RED}Error: Python is not installed.${NC}"
echo "Please install Python 3.11+ from https://python.org"
echo "Please install Python 3.11+ (recommended 3.12) from https://python.org"
exit 1
fi
# Use python3 if available, otherwise python
PYTHON_CMD="python3"
if ! command -v python3 &> /dev/null; then
# Choose Python interpreter
# Priority:
# 1) $PYTHON env var override (e.g., PYTHON=python3.11)
# 2) python3.12 if available
# 3) python3.11 if available
# 4) python3
# 5) python
PYTHON_CMD="${PYTHON:-}"
if [ -n "$PYTHON_CMD" ]; then
if [ -x "$PYTHON_CMD" ]; then
: # absolute / explicit path provided and executable
elif command -v "$PYTHON_CMD" &> /dev/null; then
: # found on PATH
else
echo -e "${RED}Error: PYTHON is set to '$PYTHON_CMD' but it was not found in PATH or as an absolute path.${NC}"
echo "Example: PYTHON=/opt/python3.12/bin/python3.12 ./scripts/setup-python.sh"
exit 1
fi
elif command -v python3.12 &> /dev/null; then
PYTHON_CMD="python3.12"
elif command -v python3.11 &> /dev/null; then
PYTHON_CMD="python3.11"
elif command -v python3 &> /dev/null; then
PYTHON_CMD="python3"
else
PYTHON_CMD="python"
fi
@@ -43,18 +75,26 @@ PYTHON_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{s
PYTHON_MAJOR=$($PYTHON_CMD -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$($PYTHON_CMD -c 'import sys; print(sys.version_info.minor)')
echo -e "${BLUE}Detected Python:${NC} $PYTHON_VERSION"
echo -e "${BLUE}Detected Python:${NC} $PYTHON_VERSION (binary: ${PYTHON_CMD})"
# Require 3.11+
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 11 ]); then
echo -e "${RED}Error: Python 3.11+ is required (found $PYTHON_VERSION)${NC}"
echo "Please upgrade your Python installation"
exit 1
fi
if [ "$PYTHON_MINOR" -lt 11 ]; then
echo -e "${YELLOW}Warning: Python 3.11+ is recommended for best compatibility${NC}"
echo -e "${YELLOW}You have Python $PYTHON_VERSION which may work but is not officially supported${NC}"
echo -e "${RED}Error: Python 3.11+ is required (found $PYTHON_VERSION via '$PYTHON_CMD')${NC}"
echo ""
echo "Fix options (Linux/macOS/WSL):"
echo " • Ubuntu/Debian/Pop!_OS/WSL:"
echo " sudo apt update && sudo apt install -y python3.11 python3.11-venv python3.11-dev"
echo " • Fedora:"
echo " sudo dnf install -y python3.11 python3.11-devel"
echo " • Arch/Manjaro:"
echo " sudo pacman -S python"
echo " • macOS (Homebrew):"
echo " brew install python@3.11"
echo ""
echo "Then re-run with an explicit interpreter if needed:"
echo " PYTHON=python3.11 ./scripts/setup-python.sh"
echo ""
exit 1
fi
echo -e "${GREEN}${NC} Python version check passed"
@@ -215,15 +255,15 @@ echo " • All dependencies and compatibility fixes applied"
echo ""
echo "To run agents, use:"
echo ""
echo " From project root: "
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m agent_name validate"
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m agent_name info"
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m agent_name run --input '{...}'"
echo " ${BLUE}# From project root:${NC}"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m agent_name validate"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m agent_name info"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m agent_name run --input '{...}'"
echo ""
echo "Available commands for your new agent:"
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m support_ticket_agent validate"
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m support_ticket_agent info"
echo " PYTHONPATH=core:exports $TOOLS_PYTHON -m support_ticket_agent run --input '{\"ticket_content\":\"...\",\"customer_id\":\"...\",\"ticket_id\":\"...\"}'"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m support_ticket_agent validate"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m support_ticket_agent info"
echo " PYTHONPATH=core:exports ${PYTHON_CMD} -m support_ticket_agent run --input '{\"ticket_content\":\"...\",\"customer_id\":\"...\",\"ticket_id\":\"...\"}'"
echo ""
echo "To build new agents, use Claude Code skills:"
echo " • /building-agents - Build a new agent"