audit_milestone.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. #!/usr/bin/env python3
  2. """List the commits since the previous release which aren't linked to an issue in the release's milestone."""
  3. import argparse
  4. import json
  5. import re
  6. import subprocess
  7. OWNER, NAME = 'netbox-community', 'netbox'
  8. BATCH_SIZE = 50
  9. ISSUE_REF = re.compile(r'\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):? #(\d+)', re.IGNORECASE)
  10. ISSUE_FIELDS = 'number title milestone { title } labels(first: 20) { nodes { name } }'
  11. def run(*args):
  12. return subprocess.run(args, capture_output=True, text=True, check=True).stdout
  13. def graphql(fields):
  14. """Query the repository. Fields which can't be resolved (e.g. an issue number which is a PR) return None."""
  15. query = f'{{ repository(owner: "{OWNER}", name: "{NAME}") {{ {" ".join(fields)} }} }}'
  16. result = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={query}'], capture_output=True, text=True)
  17. try:
  18. response = json.loads(result.stdout)
  19. except json.JSONDecodeError:
  20. raise SystemExit(result.stderr) from None
  21. if not response.get('data') or any(error.get('type') != 'NOT_FOUND' for error in response.get('errors', [])):
  22. raise SystemExit(result.stderr)
  23. return response['data']['repository']
  24. def get_commits(base, head):
  25. """Return the SHA, subject, and changed paths (relative to the first parent) of each first-parent commit."""
  26. log = run(
  27. 'git', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--name-only',
  28. '--format=%x00%H%x09%s', f'{base}..{head}',
  29. )
  30. commits = []
  31. for entry in log.split('\0')[1:]:
  32. header, *paths = entry.splitlines()
  33. sha, subject = header.split('\t', 1)
  34. commits.append((sha, subject, [path for path in paths if path]))
  35. return commits
  36. def get_areas(paths):
  37. """Return the areas of the codebase to which the given paths belong."""
  38. areas = set()
  39. for path in paths:
  40. if path.startswith('netbox/translations/'):
  41. areas.add('translations')
  42. elif '/tests/' in path:
  43. areas.add('tests')
  44. elif path.startswith('netbox/'):
  45. areas.add('app')
  46. elif path in ('base_requirements.txt', 'requirements.txt', 'pyproject.toml'):
  47. areas.add('dependencies')
  48. elif path in ('contrib/openapi.json', 'contrib/generated_schema.json'):
  49. areas.add('schema')
  50. elif path == 'upgrade.sh' or path.startswith('contrib/'):
  51. areas.add('install')
  52. elif path.startswith('docs/') or path in ('mkdocs.yml', 'README.md'):
  53. areas.add('docs')
  54. else:
  55. areas.add('tooling')
  56. return areas
  57. def get_links(commits):
  58. """Map each commit to its pull requests and to the issues it references or its PRs close."""
  59. links = {}
  60. for i in range(0, len(commits), BATCH_SIZE):
  61. batch = commits[i:i + BATCH_SIZE]
  62. data = graphql(
  63. f'c{j}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 3) {{ nodes {{ '
  64. f'number closingIssuesReferences(first: 10) {{ nodes {{ {ISSUE_FIELDS} }} }} }} }} }} }}'
  65. for j, (sha, _, _) in enumerate(batch)
  66. )
  67. for j, (sha, _, _) in enumerate(batch):
  68. # A commit which hasn't been pushed resolves to None
  69. if (commit := data[f'c{j}']) is None:
  70. links[sha] = {'prs': None, 'issues': {}}
  71. continue
  72. prs = commit['associatedPullRequests']['nodes']
  73. issues = {issue['number']: issue for pr in prs for issue in pr['closingIssuesReferences']['nodes']}
  74. links[sha] = {'prs': [pr['number'] for pr in prs], 'issues': issues}
  75. # Issues referenced only in a commit subject (e.g. "Fixes #123: ...")
  76. missing = {
  77. int(number) for sha, subject, _ in commits
  78. for number in ISSUE_REF.findall(subject) if int(number) not in links[sha]['issues']
  79. }
  80. if missing:
  81. data = graphql(f'i{number}: issue(number: {number}) {{ {ISSUE_FIELDS} }}' for number in missing)
  82. for sha, subject, _ in commits:
  83. for number in map(int, ISSUE_REF.findall(subject)):
  84. if issue := data.get(f'i{number}'):
  85. links[sha]['issues'].setdefault(number, issue)
  86. return links
  87. def escape(text):
  88. """Escape pipes for use in a Markdown table cell."""
  89. return text.replace('|', r'\|')
  90. def describe_issue(issue):
  91. milestone = issue['milestone']['title'] if issue['milestone'] else 'no milestone'
  92. types = [label['name'] for label in issue['labels']['nodes'] if label['name'].startswith('type:')]
  93. return f"#{issue['number']} ({', '.join([milestone, *types])})"
  94. def main():
  95. parser = argparse.ArgumentParser(description=__doc__)
  96. parser.add_argument('milestone', help='Release milestone, e.g. v4.7.5')
  97. parser.add_argument('--head', default='origin/main', help='Last commit of the release (default: origin/main)')
  98. parser.add_argument('--base', help='Previous release tag (default: the latest tag before --head)')
  99. args = parser.parse_args()
  100. base = args.base or run('git', 'describe', '--tags', '--abbrev=0', f'{args.head}^').strip()
  101. commits = get_commits(base, args.head)
  102. links = get_links(commits)
  103. uncovered, routine, linked_issues = [], 0, set()
  104. for sha, subject, paths in commits:
  105. issues = links[sha]['issues']
  106. linked_issues.update(issues)
  107. areas = get_areas(paths)
  108. in_milestone = any(
  109. issue['milestone'] and issue['milestone']['title'] == args.milestone for issue in issues.values()
  110. )
  111. if subject.startswith('Release v') or areas == {'translations'} or set(paths) == {'.github/VOUCHED.td'}:
  112. routine += 1
  113. # Always list reverts, which cancel out the commit they revert
  114. elif subject.startswith('Revert ') or not in_milestone:
  115. uncovered.append((sha, subject, links[sha]['prs'], issues, areas))
  116. print(f'# Milestone audit: {args.milestone} ({base}..{args.head})\n')
  117. print(
  118. f'{len(commits)} commits: {len(commits) - len(uncovered) - routine} linked to the milestone, '
  119. f'{routine} routine (releases, translations, vouched contributors), '
  120. f'{len(uncovered)} reverts or not linked to the milestone.\n'
  121. )
  122. print('## Reverts, and commits not linked to the milestone\n')
  123. print('| Commit | Subject | PRs | Linked issues | Areas changed |')
  124. print('|---|---|---|---|---|')
  125. for sha, subject, prs, issues, areas in uncovered:
  126. prs = 'not on GitHub' if prs is None else ', '.join(f'#{pr}' for pr in prs) or '-'
  127. issues = ', '.join(map(describe_issue, issues.values())) or '-'
  128. print(f"| {sha[:9]} | {escape(subject)} | {prs} | {issues} | {', '.join(sorted(areas))} |")
  129. milestone_issues = json.loads(run(
  130. 'gh', 'issue', 'list', '--repo', f'{OWNER}/{NAME}', '--milestone', args.milestone,
  131. '--state', 'closed', '--limit', '500', '--json', 'number,title,stateReason',
  132. ))
  133. unmatched = [
  134. issue for issue in milestone_issues
  135. if issue['stateReason'] == 'COMPLETED' and issue['number'] not in linked_issues
  136. ]
  137. print('\n## Milestone issues not linked to any commit\n')
  138. print('| Issue | Title |')
  139. print('|---|---|')
  140. for issue in sorted(unmatched, key=lambda issue: issue['number']):
  141. print(f"| #{issue['number']} | {escape(issue['title'])} |")
  142. if __name__ == '__main__':
  143. main()