Skip to content

Commit 660c66d

Browse files
authored
feat: auto-close issues from merge commits to default branch (#17828)
1 parent be597f9 commit 660c66d

File tree

3 files changed

+412
-0
lines changed

3 files changed

+412
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Auto-Close Issues from Merge Commits
2+
3+
on:
4+
push:
5+
branches:
6+
- next
7+
8+
jobs:
9+
auto-close-issues:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
issues: write
14+
pull-requests: read
15+
16+
steps:
17+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
18+
with:
19+
# Conservative depth that avoids historical large files while covering typical merge scenarios
20+
fetch-depth: 200
21+
22+
- name: Close issues from merged PRs
23+
env:
24+
GITHUB_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }}
25+
run: |
26+
python3 scripts/auto_close_issues.py "${{ github.event.before }}" "${{ github.sha }}"

scripts/auto_close_issues.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
"""Auto-close issues referenced in merged PRs.
3+
4+
When PRs target intermediate branches (like merge-train), GitHub's native
5+
auto-close doesn't work. This script processes new commits and closes any
6+
issues referenced in merged PRs.
7+
"""
8+
9+
import os
10+
import re
11+
import subprocess
12+
import sys
13+
import json
14+
15+
16+
def run(cmd):
17+
"""Run command and return output, or empty string on error."""
18+
try:
19+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
20+
return result.stdout.strip()
21+
except subprocess.CalledProcessError:
22+
return ""
23+
24+
25+
def gh_api(endpoint):
26+
"""Call GitHub API and return JSON, or None on error."""
27+
try:
28+
output = run(["gh", "api", endpoint])
29+
return json.loads(output) if output else None
30+
except json.JSONDecodeError:
31+
return None
32+
33+
34+
def parse_issue_ref(repo, issue_ref):
35+
"""Parse issue reference into (target_repo, issue_num)."""
36+
if '#' in issue_ref and '/' in issue_ref:
37+
# Cross-repo: owner/repo#123
38+
target_repo, issue_num = issue_ref.rsplit('#', 1)
39+
else:
40+
# Same-repo: 123
41+
target_repo, issue_num = repo, issue_ref
42+
return target_repo, issue_num
43+
44+
45+
def extract_issue_refs(text):
46+
"""Extract issue references from text like 'Closes #123' or 'Fixes owner/repo#456'."""
47+
issues = []
48+
cross_repo_refs = []
49+
50+
# First, extract all URL-based references (these work with or without keywords)
51+
for owner, repo, num in re.findall(r'https?://github\.com/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)/issues/(\d+)', text):
52+
cross_repo_refs.append(num)
53+
issues.append(f"{owner}/{repo}#{num}")
54+
55+
# Then extract keyword-based references
56+
for line in text.split('\n'):
57+
if re.search(r'\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b', line, re.IGNORECASE):
58+
# Cross-repo: owner/repo#123
59+
for owner_repo, num in re.findall(r'([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)#(\d+)', line):
60+
cross_repo_refs.append(num)
61+
issues.append(f"{owner_repo}#{num}")
62+
63+
# Same-repo: #123 (skip if already captured as cross-repo or URL)
64+
for num in re.findall(r'#(\d+)', line):
65+
if num not in cross_repo_refs:
66+
issues.append(num)
67+
68+
return list(set(issues))
69+
70+
71+
def close_issue(repo, issue_ref, pr_number, pr_title, dry_run=False):
72+
"""Close issue if it's open."""
73+
target_repo, issue_num = parse_issue_ref(repo, issue_ref)
74+
75+
# Check if issue is open
76+
issue_data = gh_api(f"repos/{target_repo}/issues/{issue_num}")
77+
if not issue_data:
78+
return False
79+
80+
state = issue_data.get('state')
81+
if state == 'closed':
82+
print(f"Already closed: {target_repo}#{issue_num} (from PR #{pr_number}: {pr_title})")
83+
return False
84+
85+
if state != 'open':
86+
return False
87+
88+
if dry_run:
89+
print(f"Would close {target_repo}#{issue_num} (from PR #{pr_number}: {pr_title})")
90+
return True
91+
92+
# Close the issue
93+
run_url = ""
94+
if os.environ.get('GITHUB_RUN_ID'):
95+
server_url = os.environ.get('GITHUB_SERVER_URL', 'https://github.com')
96+
run_url = f"\n\n[View workflow run]({server_url}/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']})"
97+
98+
comment = f"This issue was automatically closed because it was referenced in {'PR' if target_repo == repo else f'{repo} PR'} #{pr_number} which has been merged to the default branch.{run_url}"
99+
100+
result = run(["gh", "issue", "close", issue_num, "--repo", target_repo, "--comment", comment])
101+
if result or result == "": # gh issue close may return empty on success
102+
print(f"Closed {target_repo}#{issue_num} (from PR #{pr_number}: {pr_title})")
103+
return True
104+
105+
print(f"Warning: Failed to close {target_repo}#{issue_num}", file=sys.stderr)
106+
return False
107+
108+
109+
def process_commit(commit_sha, repo, dry_run=False):
110+
"""Process a commit and close any referenced issues."""
111+
# Get all PR numbers from commit message
112+
message = run(["git", "log", "-1", "--pretty=%B", commit_sha])
113+
pr_numbers = re.findall(r'#(\d+)', message)
114+
115+
for pr_number in pr_numbers:
116+
# Get PR data
117+
pr_data = gh_api(f"repos/{repo}/pulls/{pr_number}")
118+
if not pr_data or not pr_data.get('merged'):
119+
continue
120+
121+
# Find issue references in PR
122+
text = f"{pr_data.get('title', '')}\n{pr_data.get('body', '') or ''}"
123+
issue_refs = extract_issue_refs(text)
124+
125+
# Close each issue
126+
for issue_ref in issue_refs:
127+
close_issue(repo, issue_ref, pr_number, pr_data['title'], dry_run)
128+
129+
130+
def main():
131+
if len(sys.argv) < 2:
132+
print("Usage: auto_close_issues.py <before_sha> <after_sha>", file=sys.stderr)
133+
print(" or: auto_close_issues.py <commit_sha>", file=sys.stderr)
134+
sys.exit(1)
135+
136+
repo = os.environ.get('GITHUB_REPOSITORY', 'AztecProtocol/aztec-packages')
137+
dry_run = os.environ.get('DRY_RUN', '0') == '1'
138+
139+
# Get commits to process
140+
if len(sys.argv) == 3:
141+
before, after = sys.argv[1], sys.argv[2]
142+
if before == '0000000000000000000000000000000000000000':
143+
commits = [after]
144+
else:
145+
commits = [c for c in run(["git", "rev-list", f"{before}..{after}"]).split('\n') if c]
146+
else:
147+
commits = [sys.argv[1]]
148+
149+
# Process each commit
150+
for commit in commits:
151+
process_commit(commit, repo, dry_run)
152+
153+
154+
if __name__ == '__main__':
155+
main()

0 commit comments

Comments
 (0)