Skip to content

Commit ea3a08a

Browse files
committed
Add the publish command to open the backport pull requests
Pushes the branches apply built and opens one pull request per affected branch, so a fix that lands on seven branches is one command rather than seven trips through the GitHub UI. apply --open-pr offers it as soon as the cherry-picks are done, so a local session stays analyze then apply A branch is publishable once its cherry-pick is finished, which is read from git rather than from anything apply recorded. Resolving a conflict by hand is therefore enough to make the next publish pick that branch up, with no need to run apply again Branches are pushed to a fork and the pull requests are opened against aws/aws-lc. Pushing to aws/aws-lc is refused outright, so a stray --remote cannot put half-reviewed work on the real repository. A branch that already has an open pull request is left alone, so re-running is safe Everything that talks to GitHub lives in util/github.py. The previous version of this tool grew one PR opener in publish and a second in resolve, and they drifted apart Nothing is ever a draft and nothing is auto-merged
1 parent 17cfd10 commit ea3a08a

9 files changed

Lines changed: 785 additions & 9 deletions

File tree

util/backport/README.md

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ passes:
1717
review, but a no-answer always leaves the branch flagged, so it can never hide a
1818
needed backport.
1919

20-
`apply` then cherry-picks the fix onto one local branch per affected branch.
20+
`apply` then cherry-picks the fix onto one local branch per affected branch, and
21+
`publish` turns those into one pull request each.
2122

22-
Nothing is pushed and no pull request is opened. The branches are yours to review.
23+
Nothing is auto-merged, and nothing is ever a draft. Every pull request needs review.
2324

2425
## Prerequisites
2526

@@ -31,6 +32,8 @@ Nothing is pushed and no pull request is opened. The branches are yours to revie
3132
- **anthropic + boto3**: required, not optional (`pip3 install --user anthropic boto3`).
3233
The AI pass is part of how a verdict is reached, so the tool imports them at startup
3334
even when `BACKPORT_DISABLE_AI` is set
35+
- **gh**: the GitHub CLI, for `publish` only. Logged in with `gh auth login`, or
36+
`GH_TOKEN` set, which is how CI supplies it
3437

3538
### AWS Permissions
3639

@@ -176,6 +179,64 @@ normal on the older branches, where the surrounding code has moved on.
176179
leaves the branches you already have alone. The command exits non-zero if any branch
177180
conflicted, so a script can tell whether anything needs a human.
178181

182+
### Open the pull requests
183+
184+
```bash
185+
util/backport/backport publish
186+
```
187+
188+
Pushes each finished backport branch to your fork and opens one pull request per
189+
branch into the matching release branch. One command, however many branches the fix
190+
touched.
191+
192+
Most of the time you never type it: `apply --open-pr` offers to run it as soon as the
193+
cherry-picks are done, so a normal session is `analyze` then `apply`.
194+
195+
```bash
196+
util/backport/backport apply --open-pr
197+
```
198+
199+
| Flag | Purpose |
200+
| --- | --- |
201+
| `--branch` | just this release branch |
202+
| `--pr` | source pull request number, linked in each body and given a summary comment |
203+
| `--remote` | fork remote the branches are pushed to, `origin` by default |
204+
| `--dry-run` | print what would be pushed and opened, touch nothing |
205+
| `--yes` | skip the confirm, for scripts and CI |
206+
207+
Branches go to your fork; the pull requests are opened against `aws/aws-lc`. Pushing
208+
to `aws/aws-lc` is refused outright, so a stray `--remote` cannot put half-reviewed
209+
work on the real repository.
210+
211+
**Example Output:**
212+
213+
```
214+
Fix ac3aee3104
215+
Opening pull requests into aws/aws-lc for: fips-2025-09-12-lts, fips-2024-09-27
216+
Branches are pushed to 'origin'
217+
Go ahead? [Y/N] y
218+
fips-2025-09-12-lts: opened: https://github.com/aws/aws-lc/pull/3401
219+
fips-2024-09-27: unfinished: cherry-pick still open in .backport-worktrees/...
220+
221+
1 pull request(s) opened, 1 still need attention
222+
fips-2024-09-27
223+
Finish the cherry-pick in its worktree, then run publish again.
224+
```
225+
226+
**Results:**
227+
228+
| Result | Meaning |
229+
| --- | --- |
230+
| `opened` | pushed and a pull request created |
231+
| `already open` | a pull request for that branch exists, so nothing was done again |
232+
| `unfinished` | its cherry-pick is still stopped in the worktree, resolve it first |
233+
| `missing` | no such backport branch, run `apply` first |
234+
| `failed` | the push or the pull request failed, with the reason |
235+
236+
Nothing is ever a draft and nothing is auto-merged. Re-running is safe: a branch that
237+
already has a pull request is left alone, and finishing a conflict by hand is enough
238+
to let the next run pick it up, with no need to run `apply` again.
239+
179240
## Configuration
180241

181242
### Model settings
@@ -250,7 +311,8 @@ util/backport/
250311
│ ├── main.py # argument parsing
251312
│ ├── commands/
252313
│ │ ├── analyze.py # the analyze command
253-
│ │ └── apply.py # the apply command
314+
│ │ ├── apply.py # the apply command
315+
│ │ └── publish.py # the publish command
254316
│ ├── engine/
255317
│ │ ├── inspect_fix.py # which lines the fix deletes, who wrote them
256318
│ │ ├── discover_branches.py # which release branches to check
@@ -260,6 +322,7 @@ util/backport/
260322
│ └── util/
261323
│ ├── config.py # verdicts, settings, the saved run
262324
│ ├── git.py # everything that runs a git command
325+
│ ├── github.py # everything that talks to GitHub, through gh
263326
│ └── render.py # the output table and prompts
264327
├── testing/
265328
│ ├── test_engine.py # unit tests, no repo or credentials
@@ -405,6 +468,27 @@ The same error appears if the run names a fix this checkout no longer has, which
405468
happens when a range was analyzed and git has since collected the squashed commit.
406469
Re-running `analyze` fixes both.
407470

471+
### No pull request can be opened
472+
473+
`publish` needs the GitHub CLI, installed and logged in:
474+
475+
```bash
476+
gh auth login
477+
gh auth status
478+
```
479+
480+
In CI set `GH_TOKEN` instead, and give the job `contents: write` and
481+
`pull-requests: write`.
482+
483+
### Refused to push to aws/aws-lc
484+
485+
Backport branches belong on a fork; only the pull requests go to `aws/aws-lc`. Point
486+
`--remote` at your fork:
487+
488+
```bash
489+
util/backport/backport publish --remote origin
490+
```
491+
408492
### Wrong or empty results from a subdirectory
409493

410494
Should not happen. The tool pins itself to the checkout it lives in rather than

util/backport/src/commands/analyze.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,5 @@ def cmd_analyze(args) -> int:
5353
verdicts, decided_by = refine_with_ai(fix_sha, src_files, bug_commits, verdicts)
5454

5555
print_summary(fix_sha, files, bug_commits, verdicts, decided_by)
56-
save_run(fix_sha, base, branches, verdicts)
56+
save_run(fix_sha, base, branches, verdicts, decided_by)
5757
return 0

util/backport/src/commands/apply.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,5 +156,14 @@ def cmd_apply(args) -> int:
156156
"Resolve each conflict in the worktree named above, then "
157157
"'git cherry-pick --continue' there."
158158
)
159-
print("Nothing was pushed. Review each branch before you open a pull request.")
159+
160+
# Imported here, not at the top: publish imports this module for the run file and
161+
# the branch naming, so importing it up there would be a cycle
162+
from commands.publish import offer_publish
163+
164+
ready = [b for b, (o, _) in results.items() if o in (PICKED, EMPTY)]
165+
if args.open_pr:
166+
offer_publish(run, ready, args.remote)
167+
else:
168+
print("Nothing was pushed. Review each branch before you open a pull request.")
160169
return 1 if conflicted else 0
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0 OR ISC
3+
4+
"""
5+
The publish command: pushes the branches apply built and opens one pull request each
6+
Run by apply after a local cherry-pick, or on its own in CI
7+
"""
8+
9+
from commands.apply import backport_branch, load_run, pick_targets
10+
from util.config import BackportError
11+
from util.git import (
12+
WORKTREE_ROOT,
13+
cherry_pick_in_progress,
14+
commit_subject,
15+
commits_ahead,
16+
branch_exists,
17+
remove_worktree,
18+
)
19+
from util.github import (
20+
base_repo,
21+
comment_on_pr,
22+
create_pr,
23+
existing_pr,
24+
head_spec,
25+
pr_title_and_body,
26+
push_branch,
27+
require_gh,
28+
require_push_remote,
29+
summary_lines,
30+
)
31+
from util.render import ask_yn
32+
33+
from typing import Dict, List, Optional, Tuple
34+
35+
# What happened to one branch
36+
OPENED = "opened"
37+
ALREADY_OPEN = "already open"
38+
UNFINISHED = "unfinished"
39+
MISSING = "missing"
40+
FAILED = "failed"
41+
DRY_RUN = "dry run"
42+
43+
44+
def branch_state(release: str, local: str) -> str:
45+
"""
46+
Whether one backport branch is ready to become a pull request
47+
Returns MISSING when apply never made it, UNFINISHED while a cherry-pick is still
48+
stopped in its worktree, and OPENED when it holds at least one commit. Resolving a
49+
conflict by hand is enough to turn UNFINISHED into ready, with no need to re-run
50+
apply
51+
"""
52+
if not branch_exists(local):
53+
return MISSING
54+
worktree = WORKTREE_ROOT / local
55+
if worktree.exists() and cherry_pick_in_progress(worktree):
56+
return UNFINISHED
57+
if commits_ahead(f"origin/{release}", local) < 1:
58+
return MISSING
59+
return OPENED
60+
61+
62+
def publish_one(
63+
release: str,
64+
fix: str,
65+
subject: str,
66+
basis: str,
67+
source_pr: Optional[str],
68+
remote: str,
69+
push_slug: str,
70+
repo: str,
71+
dry_run: bool,
72+
) -> Tuple[str, str]:
73+
"""
74+
Pushes one finished backport branch and opens its pull request
75+
Returns (outcome, detail) where detail is a URL, a reason, or an error
76+
"""
77+
local = backport_branch(fix, release)
78+
state = branch_state(release, local)
79+
if state == MISSING:
80+
return MISSING, f"no branch {local}, run apply first"
81+
if state == UNFINISHED:
82+
return UNFINISHED, f"cherry-pick still open in {WORKTREE_ROOT / local}"
83+
84+
head = head_spec(push_slug, repo, local)
85+
open_already = existing_pr(repo, head)
86+
if open_already:
87+
return ALREADY_OPEN, open_already
88+
if dry_run:
89+
return DRY_RUN, f"would push {local} and open a PR into {release}"
90+
91+
failure = push_branch(remote, local)
92+
if failure:
93+
return FAILED, f"push failed: {failure}"
94+
95+
title, body = pr_title_and_body(release, fix, subject, basis, source_pr)
96+
url = create_pr(repo, release, head, title, body)
97+
if url.startswith("error:"):
98+
return FAILED, url
99+
# The worktree only survives a resolved conflict, and it is finished with now
100+
remove_worktree(WORKTREE_ROOT / local)
101+
return OPENED, url
102+
103+
104+
def run_publish(
105+
run: Dict,
106+
branches: List[str],
107+
remote: str,
108+
push_slug: str,
109+
source_pr: Optional[str],
110+
dry_run: bool,
111+
) -> List[Tuple[str, str, str]]:
112+
"""
113+
Publishes every named branch
114+
Returns (branch, outcome, detail) per branch, in the order given. The caller has
115+
already checked gh and the remote, so nothing here can fail on setup
116+
"""
117+
repo = base_repo("upstream")
118+
fix = run["fix"]
119+
subject = commit_subject(fix)
120+
decided = run.get("decided_by", {})
121+
122+
results = []
123+
for release in branches:
124+
outcome, detail = publish_one(
125+
release,
126+
fix,
127+
subject,
128+
decided.get(release, ""),
129+
source_pr,
130+
remote,
131+
push_slug,
132+
repo,
133+
dry_run,
134+
)
135+
results.append((release, outcome, detail))
136+
print(f" {release}: {outcome}: {detail}")
137+
return results
138+
139+
140+
def report(
141+
run: Dict, results: List[Tuple[str, str, str]], source_pr: Optional[str], repo: str
142+
) -> None:
143+
"""Prints the tally and, when a source PR was named, comments the table on it"""
144+
opened = sum(1 for _, o, _ in results if o == OPENED)
145+
stuck = [b for b, o, _ in results if o in (UNFINISHED, MISSING, FAILED)]
146+
print()
147+
print(f"{opened} pull request(s) opened, {len(stuck)} still need attention")
148+
if stuck:
149+
print(f" {', '.join(stuck)}")
150+
# The advice has to match the cause, or it sends people to the wrong place
151+
kinds = {o for _, o, _ in results}
152+
if MISSING in kinds:
153+
print(" Missing branches: run 'backport apply' first.")
154+
if UNFINISHED in kinds:
155+
print(
156+
" Unfinished branches: resolve the conflict in the worktree, then\n"
157+
" 'git cherry-pick --continue' there and run publish again."
158+
)
159+
if FAILED in kinds:
160+
print(" Failed branches: see the reason above.")
161+
if source_pr:
162+
table = summary_lines(run["fix"], commit_subject(run["fix"]), results)
163+
failure = comment_on_pr(repo, source_pr, table)
164+
if failure:
165+
print(f"could not comment on #{source_pr}: {failure}")
166+
167+
168+
def cmd_publish(args) -> int:
169+
"""
170+
Opens a backport pull request for every affected branch
171+
Returns 0 when every branch was published or already had a pull request, 1 when
172+
any branch still needs attention
173+
"""
174+
run = load_run()
175+
branches = pick_targets(run["verdicts"], args.branch)
176+
if not branches:
177+
print("No affected branches in the last analyze run, nothing to publish.")
178+
return 0
179+
180+
# Checked before anything is printed, so nobody confirms a run that cannot work
181+
require_gh()
182+
push_slug = require_push_remote(args.remote)
183+
184+
repo = base_repo("upstream")
185+
print(f"Fix {run['fix'][:10]}")
186+
print(f"Opening pull requests into {repo} for: {', '.join(branches)}")
187+
print(f"Branches are pushed to '{args.remote}'")
188+
if not args.yes and not args.dry_run and not ask_yn("Go ahead?"):
189+
print("Aborted. Nothing was pushed.")
190+
return 0
191+
192+
results = run_publish(run, branches, args.remote, push_slug, args.pr, args.dry_run)
193+
report(run, results, args.pr, repo)
194+
return 1 if any(o in (UNFINISHED, MISSING, FAILED) for _, o, _ in results) else 0
195+
196+
197+
def offer_publish(run: Dict, branches: List[str], remote: str) -> None:
198+
"""
199+
Asks whether to open the pull requests, straight after apply cherry-picked
200+
Only the branches that applied cleanly are offered, so a conflict is never
201+
published half-done
202+
"""
203+
if not branches:
204+
return
205+
print()
206+
# Checked before the question, so a missing gh is not discovered after a yes
207+
try:
208+
require_gh()
209+
push_slug = require_push_remote(remote)
210+
except BackportError as exc:
211+
print(f"Cannot open pull requests: {exc}")
212+
print("The branches are here. Fix that and run 'backport publish'.")
213+
return
214+
if not ask_yn(f"Open pull requests for {len(branches)} branch(es)?"):
215+
print("Left as local branches. Run 'backport publish' when you are ready.")
216+
return
217+
results = run_publish(run, branches, remote, push_slug, None, False)
218+
report(run, results, None, base_repo("upstream"))

0 commit comments

Comments
 (0)