diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 69d1fca9c..7ba808b79 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -47,7 +47,7 @@ jobs: 4. Next, consider the results from steps 2 and 3 and filter out false positives that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed. - 5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates). If there are no duplicates, DO NOT COMMENT. Just exit. + 5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates). If there are no duplicates, DO NOT COMMENT. Just exit. After commenting, add the `potential-duplicate` label to the issue using `gh issue edit`. Notes for your agents: - Use `gh` to interact with GitHub, rather than web fetch @@ -85,7 +85,7 @@ jobs: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} allowed_non_write_users: "*" claude_args: | - --allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task + --allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Task settings: | { "model": "claude-sonnet-4-5-20250929", diff --git a/scripts/auto_close_duplicates.py b/scripts/auto_close_duplicates.py index a52349ee4..348d86f6b 100644 --- a/scripts/auto_close_duplicates.py +++ b/scripts/auto_close_duplicates.py @@ -65,34 +65,38 @@ class GitHubClient: } self.base_url = f"https://api.github.com/repos/{owner}/{repo}" - def get_open_issues( - self, created_before: datetime, page: int = 1, per_page: int = 100 - ) -> list[Issue]: - """Fetch open issues created before a certain date.""" + def get_potential_duplicate_issues(self) -> list[Issue]: + """Fetch open issues with the potential-duplicate label.""" url = f"{self.base_url}/issues" issues = [] with httpx.Client() as client: - response = client.get( - url, - headers=self.headers, - params={"state": "open", "per_page": per_page, "page": page}, - ) - - if response.status_code != 200: - print(f"Error fetching issues: {response.status_code}") - return issues - - data = response.json() - for item in data: - # Skip pull requests - if "pull_request" in item: - continue - - created_at = datetime.fromisoformat( - item["created_at"].replace("Z", "+00:00") + page = 1 + while page <= 10: # Safety limit + response = client.get( + url, + headers=self.headers, + params={ + "state": "open", + "labels": "potential-duplicate", + "per_page": 100, + "page": page, + }, ) - if created_at <= created_before: + + if response.status_code != 200: + print(f"Error fetching issues: {response.status_code}") + break + + data = response.json() + if not data: + break + + for item in data: + # Skip pull requests + if "pull_request" in item: + continue + issues.append( Issue( number=item["number"], @@ -104,6 +108,8 @@ class GitHubClient: ) ) + page += 1 + return issues def get_issue_comments(self, issue_number: int) -> list[Comment]: @@ -168,6 +174,13 @@ class GitHubClient: return reactions + def remove_label(self, issue_number: int, label: str) -> bool: + """Remove a label from an issue.""" + url = f"{self.base_url}/issues/{issue_number}/labels/{label}" + with httpx.Client() as client: + response = client.delete(url, headers=self.headers) + return response.status_code in [200, 204] + def close_issue(self, issue_number: int, comment: str) -> bool: """Close an issue with a comment and add duplicate label.""" # First add the comment @@ -181,7 +194,9 @@ class GitHubClient: print(f"Failed to add comment to issue #{issue_number}") return False - # Add the duplicate label + # Swap labels: remove potential-duplicate, add duplicate + self.remove_label(issue_number, "potential-duplicate") + labels_url = f"{self.base_url}/issues/{issue_number}/labels" with httpx.Client() as client: response = client.post( @@ -213,22 +228,36 @@ def find_duplicate_comment(comments: list[Comment]) -> Comment | None: return None -def should_close_as_duplicate( +def was_already_auto_closed(comments: list[Comment]) -> bool: + """Check if this issue was already auto-closed once.""" + for comment in comments: + if ( + comment.user_type == "Bot" + and "closing this issue as a duplicate" in comment.body.lower() + ): + return True + return False + + +def is_past_cooldown(duplicate_comment: Comment) -> bool: + """Check if the 3-day cooldown period has passed.""" + comment_date = datetime.fromisoformat( + duplicate_comment.created_at.replace("Z", "+00:00") + ) + three_days_ago = datetime.now(timezone.utc) - timedelta(days=3) + return comment_date <= three_days_ago + + +def has_human_activity( issue: Issue, duplicate_comment: Comment, all_comments: list[Comment], reactions: list[Reaction], ) -> bool: - """Determine if an issue should be closed as duplicate.""" - - # Check if comment is old enough (3 days) + """Check if there's human activity that should prevent auto-closure.""" comment_date = datetime.fromisoformat( duplicate_comment.created_at.replace("Z", "+00:00") ) - three_days_ago = datetime.now(timezone.utc) - timedelta(days=3) - - if comment_date > three_days_ago: - return False # Check for preventing reactions (thumbs down) for reaction in reactions: @@ -236,22 +265,21 @@ def should_close_as_duplicate( print( f"Issue #{issue.number}: Has preventing reaction from {reaction.user_login}" ) - return False + return True - # Check for user activity after the duplicate comment + # Check for any human comment after the duplicate marking for comment in all_comments: comment_date_check = datetime.fromisoformat( comment.created_at.replace("Z", "+00:00") ) if comment_date_check > comment_date: - # Issue author commented after duplicate marking - if comment.user_id == issue.user_id: + if comment.user_type != "Bot": print( - f"Issue #{issue.number}: Author commented after duplicate marking" + f"Issue #{issue.number}: {comment.user_login} commented after duplicate marking" ) - return False + return True - return True + return False def main(): @@ -271,60 +299,72 @@ def main(): # Initialize client client = GitHubClient(token, owner, repo) - # Get issues created more than 3 days ago - three_days_ago = datetime.now(timezone.utc) - timedelta(days=3) + # Only fetch issues with the potential-duplicate label + all_issues = client.get_potential_duplicate_issues() - all_issues = [] - page = 1 + print(f"[DEBUG] Found {len(all_issues)} open issues with potential-duplicate label") - while page <= 20: # Safety limit - issues = client.get_open_issues(three_days_ago, page=page) - if not issues: - break - all_issues.extend(issues) - page += 1 - - print(f"[DEBUG] Found {len(all_issues)} open issues created more than 3 days ago") - - processed_count = 0 closed_count = 0 + cleared_count = 0 for issue in all_issues: - processed_count += 1 - - if processed_count % 10 == 0: - print(f"[DEBUG] Processed {processed_count}/{len(all_issues)} issues") - # Get comments for this issue comments = client.get_issue_comments(issue.number) # Look for duplicate marking comment duplicate_comment = find_duplicate_comment(comments) if not duplicate_comment: + # Label exists but no bot comment - clean up the label + print(f"[DEBUG] Issue #{issue.number} has label but no duplicate comment") + client.remove_label(issue.number, "potential-duplicate") + cleared_count += 1 + continue + + # Skip if already auto-closed once (someone reopened it intentionally) + if was_already_auto_closed(comments): + print( + f"[DEBUG] Issue #{issue.number} was already auto-closed, removing label" + ) + client.remove_label(issue.number, "potential-duplicate") + cleared_count += 1 continue print(f"[DEBUG] Issue #{issue.number} has duplicate comment") + # Still in cooldown period - skip for now, check again later + if not is_past_cooldown(duplicate_comment): + print(f"[DEBUG] Issue #{issue.number} still in 3-day cooldown period") + continue + # Get reactions on the duplicate comment reactions = client.get_comment_reactions(issue.number, duplicate_comment.id) - # Check if we should close - if should_close_as_duplicate(issue, duplicate_comment, comments, reactions): - close_message = ( - "Closing this issue as a duplicate based on the automated analysis above.\n\n" - "The duplicate issues identified contain existing discussions and potential solutions. " - "Please add your 👍 to those issues if they match your use case.\n\n" - "If this was closed in error, please leave a comment explaining why this is not " - "a duplicate and we'll reopen it." - ) + # Check for human activity that prevents closure + if has_human_activity(issue, duplicate_comment, comments, reactions): + print(f"[DEBUG] Issue #{issue.number} has human activity, removing label") + client.remove_label(issue.number, "potential-duplicate") + cleared_count += 1 + continue - if client.close_issue(issue.number, close_message): - print(f"[SUCCESS] Closed issue #{issue.number} as duplicate") - closed_count += 1 - else: - print(f"[ERROR] Failed to close issue #{issue.number}") + # No human activity after cooldown - close as duplicate + close_message = ( + "Closing this issue as a duplicate based on the automated analysis above.\n\n" + "The duplicate issues identified contain existing discussions and potential solutions. " + "Please add your 👍 to those issues if they match your use case.\n\n" + "If this was closed in error, please leave a comment explaining why this is not " + "a duplicate and we'll reopen it." + ) - print(f"[DEBUG] Processing complete. Closed {closed_count} duplicate issues") + if client.close_issue(issue.number, close_message): + print(f"[SUCCESS] Closed issue #{issue.number} as duplicate") + closed_count += 1 + else: + print(f"[ERROR] Failed to close issue #{issue.number}") + + print( + f"[DEBUG] Processing complete. Closed {closed_count} duplicates, " + f"cleared {cleared_count} from review" + ) if __name__ == "__main__":