| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- #!/usr/bin/env python3
- """List the commits since the previous release which aren't linked to an issue in the release's milestone."""
- import argparse
- import json
- import re
- import subprocess
- OWNER, NAME = 'netbox-community', 'netbox'
- BATCH_SIZE = 50
- ISSUE_REF = re.compile(r'\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):? #(\d+)', re.IGNORECASE)
- ISSUE_FIELDS = 'number title milestone { title } labels(first: 20) { nodes { name } }'
- def run(*args):
- return subprocess.run(args, capture_output=True, text=True, check=True).stdout
- def graphql(fields):
- """Query the repository. Fields which can't be resolved (e.g. an issue number which is a PR) return None."""
- query = f'{{ repository(owner: "{OWNER}", name: "{NAME}") {{ {" ".join(fields)} }} }}'
- result = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={query}'], capture_output=True, text=True)
- try:
- response = json.loads(result.stdout)
- except json.JSONDecodeError:
- raise SystemExit(result.stderr) from None
- if not response.get('data') or any(error.get('type') != 'NOT_FOUND' for error in response.get('errors', [])):
- raise SystemExit(result.stderr)
- return response['data']['repository']
- def get_commits(base, head):
- """Return the SHA, subject, and changed paths (relative to the first parent) of each first-parent commit."""
- log = run(
- 'git', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--name-only',
- '--format=%x00%H%x09%s', f'{base}..{head}',
- )
- commits = []
- for entry in log.split('\0')[1:]:
- header, *paths = entry.splitlines()
- sha, subject = header.split('\t', 1)
- commits.append((sha, subject, [path for path in paths if path]))
- return commits
- def get_areas(paths):
- """Return the areas of the codebase to which the given paths belong."""
- areas = set()
- for path in paths:
- if path.startswith('netbox/translations/'):
- areas.add('translations')
- elif '/tests/' in path:
- areas.add('tests')
- elif path.startswith('netbox/'):
- areas.add('app')
- elif path in ('base_requirements.txt', 'requirements.txt', 'pyproject.toml'):
- areas.add('dependencies')
- elif path in ('contrib/openapi.json', 'contrib/generated_schema.json'):
- areas.add('schema')
- elif path == 'upgrade.sh' or path.startswith('contrib/'):
- areas.add('install')
- elif path.startswith('docs/') or path in ('mkdocs.yml', 'README.md'):
- areas.add('docs')
- else:
- areas.add('tooling')
- return areas
- def get_links(commits):
- """Map each commit to its pull requests and to the issues it references or its PRs close."""
- links = {}
- for i in range(0, len(commits), BATCH_SIZE):
- batch = commits[i:i + BATCH_SIZE]
- data = graphql(
- f'c{j}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 3) {{ nodes {{ '
- f'number closingIssuesReferences(first: 10) {{ nodes {{ {ISSUE_FIELDS} }} }} }} }} }} }}'
- for j, (sha, _, _) in enumerate(batch)
- )
- for j, (sha, _, _) in enumerate(batch):
- # A commit which hasn't been pushed resolves to None
- if (commit := data[f'c{j}']) is None:
- links[sha] = {'prs': None, 'issues': {}}
- continue
- prs = commit['associatedPullRequests']['nodes']
- issues = {issue['number']: issue for pr in prs for issue in pr['closingIssuesReferences']['nodes']}
- links[sha] = {'prs': [pr['number'] for pr in prs], 'issues': issues}
- # Issues referenced only in a commit subject (e.g. "Fixes #123: ...")
- missing = {
- int(number) for sha, subject, _ in commits
- for number in ISSUE_REF.findall(subject) if int(number) not in links[sha]['issues']
- }
- if missing:
- data = graphql(f'i{number}: issue(number: {number}) {{ {ISSUE_FIELDS} }}' for number in missing)
- for sha, subject, _ in commits:
- for number in map(int, ISSUE_REF.findall(subject)):
- if issue := data.get(f'i{number}'):
- links[sha]['issues'].setdefault(number, issue)
- return links
- def escape(text):
- """Escape pipes for use in a Markdown table cell."""
- return text.replace('|', r'\|')
- def describe_issue(issue):
- milestone = issue['milestone']['title'] if issue['milestone'] else 'no milestone'
- types = [label['name'] for label in issue['labels']['nodes'] if label['name'].startswith('type:')]
- return f"#{issue['number']} ({', '.join([milestone, *types])})"
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('milestone', help='Release milestone, e.g. v4.7.5')
- parser.add_argument('--head', default='origin/main', help='Last commit of the release (default: origin/main)')
- parser.add_argument('--base', help='Previous release tag (default: the latest tag before --head)')
- args = parser.parse_args()
- base = args.base or run('git', 'describe', '--tags', '--abbrev=0', f'{args.head}^').strip()
- commits = get_commits(base, args.head)
- links = get_links(commits)
- uncovered, routine, linked_issues = [], 0, set()
- for sha, subject, paths in commits:
- issues = links[sha]['issues']
- linked_issues.update(issues)
- areas = get_areas(paths)
- in_milestone = any(
- issue['milestone'] and issue['milestone']['title'] == args.milestone for issue in issues.values()
- )
- if subject.startswith('Release v') or areas == {'translations'} or set(paths) == {'.github/VOUCHED.td'}:
- routine += 1
- # Always list reverts, which cancel out the commit they revert
- elif subject.startswith('Revert ') or not in_milestone:
- uncovered.append((sha, subject, links[sha]['prs'], issues, areas))
- print(f'# Milestone audit: {args.milestone} ({base}..{args.head})\n')
- print(
- f'{len(commits)} commits: {len(commits) - len(uncovered) - routine} linked to the milestone, '
- f'{routine} routine (releases, translations, vouched contributors), '
- f'{len(uncovered)} reverts or not linked to the milestone.\n'
- )
- print('## Reverts, and commits not linked to the milestone\n')
- print('| Commit | Subject | PRs | Linked issues | Areas changed |')
- print('|---|---|---|---|---|')
- for sha, subject, prs, issues, areas in uncovered:
- prs = 'not on GitHub' if prs is None else ', '.join(f'#{pr}' for pr in prs) or '-'
- issues = ', '.join(map(describe_issue, issues.values())) or '-'
- print(f"| {sha[:9]} | {escape(subject)} | {prs} | {issues} | {', '.join(sorted(areas))} |")
- milestone_issues = json.loads(run(
- 'gh', 'issue', 'list', '--repo', f'{OWNER}/{NAME}', '--milestone', args.milestone,
- '--state', 'closed', '--limit', '500', '--json', 'number,title,stateReason',
- ))
- unmatched = [
- issue for issue in milestone_issues
- if issue['stateReason'] == 'COMPLETED' and issue['number'] not in linked_issues
- ]
- print('\n## Milestone issues not linked to any commit\n')
- print('| Issue | Title |')
- print('|---|---|')
- for issue in sorted(unmatched, key=lambda issue: issue['number']):
- print(f"| #{issue['number']} | {escape(issue['title'])} |")
- if __name__ == '__main__':
- main()
|