Skip to content

Commit 48a3f6b

Browse files
authored
tools/release-trac-fixed-open-tickets: Add script that checks if there are fixed open tickets in trac by looking at git log and trac. (#8858)
1 parent cff4c24 commit 48a3f6b

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

‎createrelease‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# Review trac roadmap, are tickets closed properly?
99
# Only tickets that should be retargeted should be open.
1010
#
11+
# Execute this script to check if trac tickets can be closed
12+
# tools/release-trac-fixed-open-tickets.py
13+
#
1114
# Versioning scheme
1215
# ========================
1316
# VERSION=2.22.0 # the new release tag created
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
"""Check that Trac tickets referenced by commits since the last tag are closed.
3+
4+
Runs `git log <latest tag>..HEAD`, extracts referenced Trac ticket numbers
5+
from each commit message (e.g. "Fix #15028", "Trac #5935", "ticket 8442"),
6+
looks up each ticket's status on trac.cppcheck.net, and prints the full
7+
commit message for any commit whose ticket is not closed.
8+
"""
9+
10+
import argparse
11+
import csv
12+
import io
13+
import re
14+
import subprocess
15+
import sys
16+
import urllib.error
17+
import urllib.request
18+
19+
TRAC_BASE = 'https://trac.cppcheck.net'
20+
21+
# Applied (in order) to the commit message with the trailing GitHub PR
22+
# reference (e.g. "(#8851)") stripped from the subject line.
23+
TICKET_PATTERNS = [
24+
re.compile(r'\btrac\s*#?\s*(\d+)\b', re.IGNORECASE),
25+
re.compile(r'\bticket\s*#?\s*(\d+)\b', re.IGNORECASE),
26+
re.compile(r'#(\d+)\b'),
27+
]
28+
29+
# GitHub appends "(#NNNN)" (the PR number) to the end of squash-merged
30+
# commit subjects; strip it so it isn't mistaken for a Trac ticket ref.
31+
PR_SUFFIX = re.compile(r'\s*\(#\d+\)\s*$')
32+
33+
34+
def latest_tag():
35+
r = subprocess.run(
36+
['git', 'describe', '--tags', '--abbrev=0'],
37+
capture_output=True, text=True, check=False,
38+
)
39+
if r.returncode == 0:
40+
return r.stdout.strip()
41+
42+
# 'git describe' requires the tag to be an ancestor of HEAD, which can
43+
# fail on a shallow clone. Fall back to the highest version tag.
44+
print('Warning: git describe failed, falling back to highest version tag '
45+
f'({r.stderr.strip()})', file=sys.stderr)
46+
r = subprocess.run(
47+
['git', 'tag', '--sort=-v:refname'],
48+
capture_output=True, text=True, check=True,
49+
)
50+
tags = r.stdout.splitlines()
51+
if not tags:
52+
print('Error: no tags found', file=sys.stderr)
53+
sys.exit(1)
54+
return tags[0]
55+
56+
57+
def get_commits(rev_range):
58+
out = subprocess.run(
59+
['git', 'log', rev_range, '--format=%H%x1f%B%x1e'],
60+
capture_output=True, text=True, check=True,
61+
).stdout
62+
commits = []
63+
for chunk in out.split('\x1e'):
64+
chunk = chunk.strip('\n')
65+
if not chunk:
66+
continue
67+
h, msg = chunk.split('\x1f', 1)
68+
commits.append((h, msg))
69+
return commits
70+
71+
72+
def extract_ticket_numbers(message):
73+
# Ticket references live in the commit subject; the body can contain
74+
# unrelated "#N" text (e.g. stack trace frames like "#0 0x...") that
75+
# would otherwise be mistaken for ticket refs.
76+
subject = message.splitlines()[0] if message else ''
77+
subject = PR_SUFFIX.sub('', subject)
78+
found = []
79+
for pat in TICKET_PATTERNS:
80+
for m in pat.finditer(subject):
81+
n = m.group(1)
82+
if n not in found:
83+
found.append(n)
84+
return found
85+
86+
87+
def ticket_status(ticket_id, cache):
88+
if ticket_id in cache:
89+
return cache[ticket_id]
90+
url = f'{TRAC_BASE}/ticket/{ticket_id}?format=csv'
91+
try:
92+
with urllib.request.urlopen(url, timeout=15) as resp:
93+
data = resp.read().decode('utf-8-sig')
94+
except urllib.error.URLError as e:
95+
print(f'Warning: failed to fetch ticket #{ticket_id}: {e}', file=sys.stderr)
96+
cache[ticket_id] = None
97+
return None
98+
row = next(csv.DictReader(io.StringIO(data)), None)
99+
status = row['status'] if row else None
100+
cache[ticket_id] = status
101+
return status
102+
103+
104+
def main():
105+
parser = argparse.ArgumentParser(description=__doc__)
106+
parser.add_argument('--from-tag', help='starting tag/rev (default: latest tag)')
107+
parser.add_argument('--to', default='HEAD', help='ending rev (default: HEAD)')
108+
args = parser.parse_args()
109+
110+
from_rev = args.from_tag or latest_tag()
111+
rev_range = f'{from_rev}..{args.to}'
112+
print(f'Checking commits in {rev_range}', file=sys.stderr)
113+
114+
commits = get_commits(rev_range)
115+
cache = {}
116+
checked_tickets = set()
117+
open_commits = 0
118+
119+
for h, msg in commits:
120+
tickets = extract_ticket_numbers(msg)
121+
for t in tickets:
122+
checked_tickets.add(t)
123+
status = ticket_status(t, cache)
124+
if status is None:
125+
print(f'--- Ticket #{t} status UNKNOWN (commit {h[:10]}) ---')
126+
print(msg.rstrip('\n'))
127+
print()
128+
open_commits += 1
129+
elif status != 'closed':
130+
print(f'--- Ticket #{t} is OPEN (status: {status}) (commit {h[:10]}) ---')
131+
print(msg.rstrip('\n'))
132+
print()
133+
open_commits += 1
134+
135+
print(f'Checked {len(checked_tickets)} ticket(s) referenced in {len(commits)} commit(s).', file=sys.stderr)
136+
if open_commits:
137+
print(f'{open_commits} commit(s) reference a ticket that is not closed.', file=sys.stderr)
138+
sys.exit(1)
139+
print('All referenced tickets are closed.', file=sys.stderr)
140+
141+
142+
if __name__ == '__main__':
143+
main()

0 commit comments

Comments
 (0)