Skip to content

Commit 8114b0d

Browse files
Merge pull request #6993 from github/auto-merge-openapi-updates
Auto-merge OpenAPI description update PRs
2 parents fee3373 + a2a9800 commit 8114b0d

5 files changed

Lines changed: 1159 additions & 1 deletion

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
"""Select the OpenAPI description update PRs to merge, and the superseded ones.
3+
4+
Reads the pages produced by
5+
6+
gh api --paginate --slurp "repos/{repo}/pulls?state=open&per_page=100"
7+
8+
on stdin and writes `key=value` lines suitable for `$GITHUB_OUTPUT`.
9+
10+
Every open pull request is read, following pagination to the end, because a
11+
capped listing would silently drop the oldest matches. Those are exactly the
12+
superseded pull requests that must be closed: one left open becomes the
13+
newest open pull request for its title once the merged one closes, and
14+
merging it would put an earlier description back on the default branch.
15+
16+
Two properties matter for safety and are the reason this is a script rather
17+
than an inline `jq` filter:
18+
19+
* Only pull requests whose title is *exactly* one of the two recognised
20+
description-update titles are considered. Any other pull request by the
21+
same author is neither merged nor closed.
22+
* A title only contributes superseded pull requests when its own newest
23+
pull request was selected, so nothing is closed unless a validated
24+
replacement for that exact title is being merged.
25+
* Superseded numbers are emitted per title rather than in one list, so the
26+
workflow can confirm a pull request still carries the title it was
27+
selected under before closing it.
28+
* The head commit SHA of each selected pull request is emitted alongside its
29+
number, so every later step can pin to the exact commit that was inspected
30+
instead of resolving a branch name again.
31+
* The author is matched here rather than by a listing flag, because the
32+
endpoint that pages over every open pull request cannot filter by author.
33+
Keeping the author and title rules together means one tested place decides
34+
what is eligible.
35+
"""
36+
37+
import argparse
38+
import json
39+
import sys
40+
41+
TITLE_30 = 'Update OpenAPI 3.0 Descriptions'
42+
TITLE_31 = 'Update OpenAPI 3.1 Descriptions'
43+
TITLES = (TITLE_30, TITLE_31)
44+
# Output suffix -> exact title. The workflow reads a title through `--title`
45+
# so the two files never drift apart.
46+
GROUPS = (('30', TITLE_30), ('31', TITLE_31))
47+
48+
49+
def normalise(payload, author):
50+
"""Flatten paginated pages and map API fields to the names used below.
51+
52+
`--slurp` yields one list per page, so the payload is a list of lists.
53+
A single flat list is accepted too, which keeps the parsing rules the
54+
same whichever way the input was produced.
55+
"""
56+
flat = []
57+
for entry in payload:
58+
if isinstance(entry, list):
59+
flat.extend(entry)
60+
else:
61+
flat.append(entry)
62+
63+
normalised = []
64+
for pr in flat:
65+
if not isinstance(pr, dict):
66+
continue
67+
# An entry whose author cannot be read is skipped rather than
68+
# assumed to match: it must never become a merge candidate, and it
69+
# must never be closed as superseded.
70+
user = pr.get('user')
71+
login = user.get('login') if isinstance(user, dict) else None
72+
if login != author:
73+
continue
74+
head = pr.get('head')
75+
normalised.append(
76+
{
77+
'number': pr.get('number'),
78+
'title': pr.get('title'),
79+
'headRefOid': head.get('sha') if isinstance(head, dict) else None,
80+
'createdAt': pr.get('created_at'),
81+
}
82+
)
83+
84+
return normalised
85+
86+
87+
def _sort_key(pr):
88+
# `createdAt` is RFC 3339 in UTC, so lexical order is chronological.
89+
# The number breaks ties deterministically.
90+
return (pr.get('createdAt') or '', pr.get('number') or 0)
91+
92+
93+
def select(pull_requests):
94+
"""Return the pinnable newest PR per recognised title, and the superseded.
95+
96+
A title only contributes superseded pull requests when its own newest
97+
pull request was actually selected. Nothing is ever closed on the basis
98+
of a replacement that was not validated. The superseded numbers are keyed
99+
by title so the caller can re-check that association before closing.
100+
"""
101+
recognised = [
102+
pr
103+
for pr in pull_requests
104+
if isinstance(pr, dict) and pr.get('title') in TITLES and pr.get('number')
105+
]
106+
107+
selected = {}
108+
superseded = {title: [] for title in TITLES}
109+
for title in TITLES:
110+
candidates = [pr for pr in recognised if pr['title'] == title]
111+
if not candidates:
112+
continue
113+
newest = max(candidates, key=_sort_key)
114+
# Without a head SHA the newest pull request cannot be pinned to an
115+
# exact commit, so it is not merged from a mutable branch instead --
116+
# and none of its older siblings are closed.
117+
if not newest.get('headRefOid'):
118+
continue
119+
selected[title] = newest
120+
superseded[title] = sorted(
121+
pr['number'] for pr in candidates if pr['number'] != newest['number']
122+
)
123+
124+
return selected, superseded
125+
126+
127+
def outputs(pull_requests):
128+
selected, superseded = select(pull_requests)
129+
130+
lines = [f'found={"true" if selected else "false"}']
131+
for suffix, title in GROUPS:
132+
pr = selected.get(title)
133+
lines.append(f'pr_{suffix}={pr["number"] if pr else ""}')
134+
lines.append(f'sha_{suffix}={pr["headRefOid"] if pr else ""}')
135+
lines.append(
136+
f'superseded_{suffix}=' + ' '.join(str(n) for n in superseded[title])
137+
)
138+
139+
return lines
140+
141+
142+
def main(argv=None):
143+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
144+
parser.add_argument(
145+
'--author',
146+
help='only consider pull requests opened by this login',
147+
)
148+
parser.add_argument(
149+
'--title',
150+
choices=[suffix for suffix, _ in GROUPS],
151+
help='print the exact recognised title for one output group and exit',
152+
)
153+
args = parser.parse_args(argv)
154+
155+
if args.title:
156+
print(dict(GROUPS)[args.title])
157+
return 0
158+
159+
if not args.author:
160+
print('--author is required when selecting', file=sys.stderr)
161+
return 2
162+
163+
payload = json.load(sys.stdin)
164+
if not isinstance(payload, list):
165+
print('expected a JSON array of pull requests', file=sys.stderr)
166+
return 2
167+
168+
for line in outputs(normalise(payload, args.author)):
169+
print(line)
170+
return 0
171+
172+
173+
if __name__ == '__main__':
174+
sys.exit(main())

0 commit comments

Comments
 (0)