diff --git a/.github/scripts/close_duplicate_issues.py b/.github/scripts/close_duplicate_issues.py new file mode 100755 index 00000000000..4e17e1d6d8b --- /dev/null +++ b/.github/scripts/close_duplicate_issues.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Detect and close duplicate GitHub issues using title similarity. + +Modes: + --scan Compare all open issues against each other (batch) + --issue-number N Check a single issue against older open issues + +Requires the `gh` CLI to be authenticated. +""" + +import argparse +import difflib +import json +import re +import subprocess +import sys + + +def normalize_title(title: str) -> str: + """Strip common prefixes, lowercase, and collapse whitespace.""" + title = re.sub( + r"^\[?(bug|feature request|enhancement|question|docs)[:\]]?\s*", + "", + title, + flags=re.IGNORECASE, + ) + return " ".join(title.lower().split()) + + +def gh(*args: str) -> str: + """Run a gh CLI command and return stdout.""" + result = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def fetch_open_issues(repo: str | None) -> list[dict]: + """Fetch all open issues (excluding PRs) via gh api --paginate.""" + if repo: + endpoint = f"repos/{repo}/issues?state=open&per_page=100&sort=created&direction=asc" + else: + endpoint = "repos/{owner}/{repo}/issues?state=open&per_page=100&sort=created&direction=asc" + cmd = ["api", "--paginate", endpoint] + + raw = gh(*cmd) + # gh --paginate concatenates JSON arrays, so we may get multiple arrays + issues = [] + for line in raw.strip().splitlines(): + line = line.strip() + if not line: + continue + parsed = json.loads(line) + if isinstance(parsed, list): + issues.extend(parsed) + else: + issues.append(parsed) + + # Filter out pull requests (they also appear in the issues endpoint) + return [i for i in issues if "pull_request" not in i] + + +def close_as_duplicate( + issue_number: int, duplicate_of: int, repo: str | None, dry_run: bool +) -> None: + """Close an issue as duplicate of another, adding a comment and label.""" + repo_args = ["--repo", repo] if repo else [] + + if dry_run: + print(f" [DRY RUN] Would close #{issue_number} as duplicate of #{duplicate_of}") + return + + # Add comment + comment_body = ( + f"Closing as duplicate of #{duplicate_of}.\n\n" + "If you believe this is not a duplicate, please reopen and add context " + "explaining how this differs." + ) + gh("issue", "comment", str(issue_number), "--body", comment_body, *repo_args) + + # Add label + gh("issue", "edit", str(issue_number), "--add-label", "duplicate", *repo_args) + + # Close with not_planned reason + gh( + "api", + f"repos/{repo or '{owner}/{repo}'}/issues/{issue_number}", + "-X", + "PATCH", + "-f", + "state=closed", + "-f", + "state_reason=not_planned", + ) + + print(f" Closed #{issue_number} as duplicate of #{duplicate_of}") + + +def find_duplicate( + issue: dict, candidates: list[dict], threshold: float +) -> dict | None: + """Return the first candidate whose normalized title is above threshold.""" + norm = normalize_title(issue["title"]) + for candidate in candidates: + if candidate["number"] == issue["number"]: + continue + cand_norm = normalize_title(candidate["title"]) + ratio = difflib.SequenceMatcher(None, norm, cand_norm).ratio() + if ratio >= threshold: + return candidate + return None + + +def scan_all(issues: list[dict], threshold: float, repo: str | None, dry_run: bool) -> int: + """Compare every issue against all older issues. Returns count of duplicates found.""" + # Sort oldest first + issues.sort(key=lambda i: i["number"]) + closed_count = 0 + + for idx, issue in enumerate(issues): + older = issues[:idx] + if not older: + continue + dup = find_duplicate(issue, older, threshold) + if dup: + ratio = difflib.SequenceMatcher( + None, + normalize_title(issue["title"]), + normalize_title(dup["title"]), + ).ratio() + print( + f"#{issue['number']}: \"{issue['title']}\"\n" + f" -> duplicate of #{dup['number']}: \"{dup['title']}\" " + f"({ratio:.0%} similar)" + ) + close_as_duplicate(issue["number"], dup["number"], repo, dry_run) + closed_count += 1 + + return closed_count + + +def check_single( + issue_number: int, issues: list[dict], threshold: float, repo: str | None, dry_run: bool +) -> bool: + """Check a single issue against all older open issues. Returns True if duplicate found.""" + target = None + for i in issues: + if i["number"] == issue_number: + target = i + break + + if target is None: + print(f"Issue #{issue_number} not found among open issues.") + return False + + older = [i for i in issues if i["number"] < issue_number] + dup = find_duplicate(target, older, threshold) + if dup: + ratio = difflib.SequenceMatcher( + None, + normalize_title(target["title"]), + normalize_title(dup["title"]), + ).ratio() + print( + f"#{target['number']}: \"{target['title']}\"\n" + f" -> duplicate of #{dup['number']}: \"{dup['title']}\" " + f"({ratio:.0%} similar)" + ) + close_as_duplicate(issue_number, dup["number"], repo, dry_run) + return True + + print(f"#{issue_number}: no duplicate found above threshold {threshold}") + return False + + +def main() -> None: + parser = argparse.ArgumentParser(description="Detect and close duplicate GitHub issues") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--scan", action="store_true", help="Scan all open issues") + mode.add_argument("--issue-number", type=int, help="Check a single issue number") + parser.add_argument("--threshold", type=float, default=0.85, help="Similarity threshold (0-1)") + parser.add_argument("--close", action="store_true", help="Actually close duplicates (default is dry-run)") + parser.add_argument("--repo", type=str, help="Repository (owner/repo). Auto-detected if omitted.") + args = parser.parse_args() + + dry_run = not args.close + + if dry_run: + print("=== DRY RUN MODE (pass --close to actually close issues) ===\n") + + print("Fetching open issues...") + issues = fetch_open_issues(args.repo) + print(f"Found {len(issues)} open issues.\n") + + if args.scan: + count = scan_all(issues, args.threshold, args.repo, dry_run) + print(f"\nTotal duplicates {'found' if dry_run else 'closed'}: {count}") + else: + found = check_single(args.issue_number, issues, args.threshold, args.repo, dry_run) + sys.exit(0 if found else 0) # Always exit 0; finding no dup is not an error + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 9477dd2f8e2..6d11ce573eb 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -27,3 +27,26 @@ jobs: {{/issues}} Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference. + + - name: Checkout close script + if: github.event.action == 'opened' + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + - name: Set up Python + if: github.event.action == 'opened' + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Auto-close if high-confidence duplicate + if: github.event.action == 'opened' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 .github/scripts/close_duplicate_issues.py \ + --issue-number ${{ github.event.issue.number }} \ + --repo ${{ github.repository }} \ + --threshold 0.85 \ + --close diff --git a/.github/workflows/scan_duplicate_issues.yml b/.github/workflows/scan_duplicate_issues.yml new file mode 100644 index 00000000000..eb001db33e1 --- /dev/null +++ b/.github/workflows/scan_duplicate_issues.yml @@ -0,0 +1,49 @@ +name: Scan Duplicate Issues (One-Time) + +on: + workflow_dispatch: + inputs: + threshold: + description: "Similarity threshold (0-1)" + required: false + default: "0.85" + close: + description: "Actually close duplicates (false = dry run)" + required: false + type: boolean + default: false + +jobs: + scan: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + steps: + - name: Checkout scripts + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Scan for duplicate issues + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_THRESHOLD: ${{ inputs.threshold }} + INPUT_CLOSE: ${{ inputs.close }} + run: | + CLOSE_FLAG="" + if [ "$INPUT_CLOSE" = "true" ]; then + CLOSE_FLAG="--close" + fi + python3 .github/scripts/close_duplicate_issues.py \ + --scan \ + --repo ${{ github.repository }} \ + --threshold "$INPUT_THRESHOLD" \ + $CLOSE_FLAG