feat(release): keep a line's backport record and reports across batch pull requests (#5264)

## What this PR does

This PR keeps backport batch record of a line across batch pull
requests. Before it lived only in body of open batch PR, so once that PR
merged or closed it was gone, and a conflict deferred in merged batch
showed up as missing in `check` and on cut. Three `check --cut` results
also did not match the docs, all three are fixed here.

- `check --cut` fails when workflow original is the only thing left
waiting. Before it passed as soon as pick next to it got `backport/skip`
or landed by hand.
- `run`, `check`, `fold` and `notify` read line record from newest
trusted batch PR in any state (opened by bot or by someone with write or
admin access). If newest record is in doubt (author no longer trusted,
bot batch without block, unreadable block, closed record newer than open
batch) command stops and doesn't fall back to older one.
- Record is written only while its PR is open. Merged and closed batch
PRs are never edited or commented, so run that picks nothing with no
open batch writes nothing and says so.
- Every run makes report: each original merged to `main` since line
branched in exactly one category, what the run read (heads, policy, tool
commit, Actions run, flags) and each drop with its reason and merge
commit. Latest report sits in body next to record and each one also goes
to immutable comment. Report ID is hash of classification without PR
number, so identical runs post one comment. `backport-batch report --pr
N [--id ID]` reads it back, merged batches too, it needs `main` in local
clone to check drops.
- `run --defer --push` of last pick used to refuse with "close batch
pull request instead". Now it writes report and record with reason while
PR is open, then closes PR and deletes branch, both only while head is
still the same. If close stops halfway next `run --push` finishes it,
and it never deletes branch that moved or that new batch uses.
- `run --drop N --reason R` keeps original off one line, `--undrop`
withdraws it. Drop is stored in line record bound to merge commit and
carried to next batches. Original the line already carries can't be
dropped, and `backport/include` doesn't lift a drop.
- Output names policy rules instead of internal numbers (no more "rule
101"), `--push` prints PR URL.

Reasons and file names from record are accepted only in the clean
one-line form a run writes them, and everything printed is escaped.

Record version 2 reads unversioned blocks that open batches carry today.
I ran new tool next to current one without `--push` on today's history
for release-1.7 and release-1.6, every original got same category and
`check` output is the same.

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(release): backport batch keeps line record and report of every run across batch pull requests, check --cut fails on workflow change left waiting alone, deferred conflict stays deferred after its batch merges, --defer of last pick closes the batch, and new run --drop keeps a fix off one line.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Maintainers can permanently exclude a change from a release line with
`--drop`, or undo that decision with `--undrop`.
* Added a `report` command to view validated batch reports in JSON or a
readable summary.
* Deferring every remaining change can close an emptied batch. A later
`run --push` can complete an interrupted close.
* Batch reports now show change classifications, including deferred and
dropped items.

* **Bug Fixes**
* Checks more clearly distinguish missing, deferred, skipped, and
carried changes, with cut checks enforcing outstanding workflow
requirements.
* Batch operations refuse edited or untrusted records and avoid writing
to closed pull requests.

* **Documentation**
* Updated command and release-process guidance for drops, reports, cut
checks, and batch closure.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
myasnikovdaniil authored and GitHub committed 2026-11-11 12:12:39 +05:00
commit c6cba19946
33 files changed
+4985 -263

No files matched your search

+33 -8
View File
@@ -9,16 +9,19 @@ go build ./cmd/backport-batch
./backport-batch run --line release-1.7 [--onto SHA] [--date YYYY-MM-DD] [--no-workflow-files] [--push] [--policy REF]
./backport-batch run --line release-1.7 --continue [--no-workflow-files] [--push] [--policy REF]
./backport-batch run --line release-1.7 --defer 4321[,4322] --reason "fails the e2e of the app" [--push] [--policy REF]
./backport-batch run --line release-1.7 --drop 4321[,4322] --reason "builds on the 1.8 storage rework" [--push] [--policy REF]
./backport-batch run --line release-1.7 --undrop 4321[,4322] [--push] [--policy REF]
./backport-batch check --line release-1.7 [--cut] [--policy REF]
./backport-batch fold --line release-1.7 --original 4321 --commit SHA [--policy REF]
./backport-batch verify --line release-1.7 [--head SHA]
./backport-batch notify --line release-1.7 [--mention org/team|login] [--push]
./backport-batch report --pr 5246 [--id ID] [--json]
./backport-batch lines [--policy REF]
```
[`hack/backport-batch-scheduled.sh`](../../hack/backport-batch-scheduled.sh) runs `lines`, then `run --continue --push`, `check` and `notify` for each line; [`backport-batch.yaml`](../../.github/workflows/backport-batch.yaml) runs it on schedule as the cozystack-ci App, with `--no-workflow-files`, which it hands to `run`.
Requires `git` and an authenticated `gh` on PATH, and a remote called `origin`. Every command but `notify` fetches `origin` first; `notify` reads only GitHub. The batch runs only for the lines `lines` prints.
Requires `git` and an authenticated `gh` on PATH, and a remote called `origin`. Every command but `notify` and `report` fetches `origin` first; `notify` reads only GitHub, and `report` reads GitHub and, for a report that keeps drops, the clone's history. The batch runs only for the lines `lines` prints.
`run`, `check`, `fold` and `lines` read the `backport-policy` block of the policy page from `origin/main`, or from the ref `--policy` names, which is how a change to the page is tried before it merges; `verify` and `notify` do not read it and take no `--policy`. A line the block lists takes nothing merged into `main` before the day it gives, unless the original carries `backport/include`; any other line starts at its fork point. A page without exactly one readable block stops the run with exit 2.
@@ -28,21 +31,25 @@ Build the binary wherever an exit code matters: `go run` turns any non-zero exit
Switches the current working tree to `backport-batch/<line>-<yyyymmdd>`, created at `origin/<line>`, and picks onto it one original at a time, in the order they merged into `main`, as one unit each on the branch's first-parent line. A pull request merged into `main` with a merge commit `M` goes on as its own commits, `git rev-list --reverse --topo-order --no-merges M^1..M^2`, each picked with `git cherry-pick -x --signoff`, so each keeps its author, message and sign-offs and gains the `(cherry picked from commit …)` line; the merges that brought `main` into its branch are left out, since `main` already has what they brought. A merge commit then joins the picks to the batch: its first parent is the batch before the original, its second the last pick, and its message is `M`'s with a paragraph of its own, `Backported-from: M`. A pull request squash-merged into `main` goes on as one pick. When any commit of an original conflicts, the pick is aborted, the branch goes back to where it was before that original, and the original is recorded with the commit that failed and its files; the run goes on with the next one. A commit the branch already carries is skipped as empty, and an original none of whose commits changes anything is reported as carried. The tree has to be clean. A local branch of that name is reset to where the run starts; when that drops commits only it has, the run first prints its tip and the `git branch` command that restores them, and so does `--defer`. Signing follows your git configuration; hooks are off for the picks and the merge, so each commit carries its message as git writes it.
The pull request body goes to stdout, built from the line's own `.github/PULL_REQUEST_TEMPLATE.md`: the originals on the branch, each with how many commits it brought and which of them differ from the commit they were picked from by `git patch-id`, a resolution or a pick git had to fit, the deferred conflicts with the commit that failed and its files, the skipped pull requests counted by rule with the lists collapsed, the originals' release notes, and a `backport-batch` block of JSON that lists the deferred conflicts for `check`. The title is `fix(backport): batch for <line>, <date>`. A body that would pass GitHub's 65536-byte limit leaves out its longest skipped lists, and the run prints them on stderr instead.
The pull request body goes to stdout, built from the line's own `.github/PULL_REQUEST_TEMPLATE.md`: the originals on the branch, each with how many commits it brought and which of them differ from the commit they were picked from by `git patch-id`, a resolution or a pick git had to fit, the deferred conflicts with the commit that failed and its files, the skipped pull requests counted by rule with the lists collapsed, the originals' release notes, and two blocks of JSON: the [line's record](#the-lines-record-and-reports) in `backport-batch`, which later runs, `check` and `notify` act on, and the run's report in `backport-batch-report`. The title is `fix(backport): batch for <line>, <date>`. A body that would pass GitHub's 65536-byte limit leaves out its longest skipped lists, and the run prints them on stderr instead; one that what it has to carry still puts over the limit is refused before anything is pushed.
`--onto SHA` continues a batch already pushed instead of starting a new one. SHA has to be the head the remote has for a batch branch of the line right now, read with `git ls-remote`, so a head someone pushed onto since is refused. What that branch carries stays, and only what it does not carry yet is picked on top, so the next push is a fast-forward. `--continue` takes SHA from the open batch pull request, and starts a new batch when none is open, which is what the scheduled run does.
`--no-workflow-files` is for a token that may not push a change to `.github/workflows/`, which the scheduled run's App token may not. An original that changes anything there is not picked: the body lists it under "Waiting for a maintainer" with those files, and the `backport-batch` block records it with `"workflows": true`, so `check`, `notify` and a run that continues the batch know it waits. It is never a conflict. A run without the flag, a maintainer's with their own token, picks it like any other original, onto the open batch when it continues one.
`--defer N[,N] --reason TEXT` takes originals out of the open batch pull request, usually the ones a failing check was pinned on. It builds the batch branch again off `origin/<line>` without their units: while the batch still sits on the line's head, the units before the first one it drops stay as they are, and every later one, a pushed resolution included, is picked again in its own shape, a merge's picks one by one and then the merge with its message. When dropping one leaves a later commit that no longer applies, the run stops and names that commit's original, to defer as well. It adds no new original. It also takes out an original that got `backport/skip` after the batch picked it, which a run never removes on its own and the body lists as still on the branch. The body records each deferred original with the reason in the `backport-batch` block; a run that continues this batch keeps them out, and the next batch tries them again. A batch that carries a merge commit other than an original's, as GitHub's update-branch button makes, is refused, since picking such a merge again would lose what it resolved.
`--defer N[,N] --reason TEXT` takes originals out of the open batch pull request, usually the ones a failing check was pinned on. It builds the batch branch again off `origin/<line>` without their units: while the batch still sits on the line's head, the units before the first one it drops stay as they are, and every later one, a pushed resolution included, is picked again in its own shape, a merge's picks one by one and then the merge with its message. When dropping one leaves a later commit that no longer applies, the run stops and names that commit's original, to defer as well. It adds no new original. It also takes out an original that got `backport/skip` after the batch picked it, which a run never removes on its own and the body lists as still on the branch. The body records each deferred original with the reason in the `backport-batch` block; a run that continues this batch keeps them out, and the next batch tries them again. A batch that carries a merge commit other than an original's, as GitHub's update-branch button makes, is refused, since picking such a merge again would lose what it resolved. When it takes out everything the batch carries, there is nothing left to push, and `--push` closes the batch pull request instead, as [the line's record](#the-lines-record-and-reports) describes.
`--push` pushes the branch, refusing anything but a fast-forward of what the remote has, then opens the draft pull request against the line with `gh pr create --draft`, or updates the open batch pull request with `gh pr edit`. When the branch it refuses to push over has no pull request open on it, as a run that stopped between its push and opening the pull request leaves it, the refusal names the `run --onto <its head> --push` that continues it. While a batch pull request is open on another branch it refuses, so a line never has two. It never pushes to or edits a batch pull request whose body has no `backport-batch` block either: someone wrote that body by hand, and the batch is theirs to merge or close. After `--defer` alone it replaces the branch, with `--force-with-lease=refs/heads/<branch>:<head>`, where head is what `git ls-remote` reads for the branch and has to be the head the rebuild started from: a commit pushed since is never dropped, by the tool's check or, if one lands between that check and the push, by the remote.
`--drop N[,N] --reason TEXT` drops originals from the line: where `--defer` holds one out of this batch, a drop keeps it out of every later batch of the line too, until `--undrop N[,N]` withdraws it. A drop is recorded in the [line's record](#the-lines-record-and-reports), so both need an open batch pull request that carries one, and exit 2 without it; once a batch merges, its drops are carried into the next batch's record. A dropped original the open batch carries is taken off it as `--defer` would, and the rebuild is pushed the same way; any other drop or undrop changes only the record, so the batch stays as it is even when the line moved since. An undropped original is missing until the next run tries it. A drop names the original's merge commit on `main` as well as its number, and a record whose drop does not match what that merge commit merged stops every command with exit 2. An original the line already carries cannot be dropped, and neither can a number that merged into `main` before the line branched. The order is the rules', then the drop: what `backport/skip`, rules 2 to 4 or the line itself decide comes first, with the drop kept on record beside it, and `backport/include` does not lift a drop. The body lists dropped originals under their own heading among the skipped ones, `check` prints a note for each, and `fold` refuses one.
`--push` pushes the branch, refusing anything but a fast-forward of what the remote has, then opens the draft pull request against the line with `gh pr create --draft`, or updates the open batch pull request with `gh pr edit`, and prints its URL on stderr. On the open batch it first posts the run's report as a comment, so a body never names a report nobody can read; a new batch carries the report in the body it opens with, and the comment follows. Before every write to the pull request it reads the pull request again, and stops with exit 2 once it is merged or closed. When the branch it refuses to push over has no pull request open on it, as a run that stopped between its push and opening the pull request leaves it, the refusal names the `run --onto <its head> --push` that continues it. While a batch pull request is open on another branch it refuses, so a line never has two. It never pushes to or edits a batch pull request whose body has no `backport-batch` block either: someone wrote that body by hand, and the batch is theirs to merge or close. After `--defer` alone it replaces the branch, with `--force-with-lease=refs/heads/<branch>:<head>`, where head is what `git ls-remote` reads for the branch and has to be the head the rebuild started from: a commit pushed since is never dropped, by the tool's check or, if one lands between that check and the push, by the remote. A record that says the batch is being closed is acted on before anything else, and only by `run --push`.
### check
Exits 1 and prints the URL of every pull request the rules take that is neither on the line nor on the open batch pull request, 0 when there is none, and 2 when it cannot tell. A pull request that the open batch lists as deferred, a conflict or one taken out with `--defer`, prints as a warning and does not fail the check: deferring is a maintainer's call. So does one it lists as waiting for a maintainer because it changes workflows.
Exits 1 and prints the URL of every pull request the rules take that is neither on the line nor on the open batch pull request, 0 when there is none, and 2 when it cannot tell. A pull request that the [line's record](#the-lines-record-and-reports) lists as deferred, a conflict or one taken out with `--defer`, prints as a warning and does not fail the check: deferring is a maintainer's call. That holds once the batch that recorded it merged or closed too. So does one the open batch lists as waiting for a maintainer because it changes workflows; once that batch is gone, nothing lists it any more, and it is missing. One a maintainer dropped from the line prints as a note, `#N is dropped from <line> on #M (<reason>)`.
`--cut` makes it the gate before a patch release or an rc is cut from a line. A cut tags the line, so a pull request that is only on the open batch pull request fails it too, with one line each: `#N is only on open batch PR #M, merge it before the cut`. One that waits there for a maintainer fails it as well, since nobody decided to leave it out: bring it over, or take it out with `run --defer` and a reason. Everything else is as without it. The scheduled run checks without `--cut`, since its batch is open by design.
`--cut` makes it the gate before a patch release or an rc is cut from a line. A cut tags the line, so a pull request that is only on the open batch pull request fails it too, with one line each: `#N is only on open batch PR #M, merge it before the cut`. One that waits there for a maintainer fails it as well, since nobody decided to leave it out, also when it is all the batch has left: bring it over, or take it out with `run --defer` and a reason. Everything else is as without it, and a cut that passes says `no change the policy takes is missing before the cut`. The scheduled run checks without `--cut`, since its batch is open by design.
An original with `backport/skip` that the open batch picked before the label came prints a warning in either mode, naming the batch and `run --defer`, which takes it out; it fails neither.
### fold
@@ -54,12 +61,16 @@ Holds every unit of a batch to the shape fold checks for one, the clean picks as
### notify
Reads the open batch pull request of the line, trusted as described below, and prints one line for each thing that waits on it: a batch open for three days or more, the shortest gap between two scheduled runs, so one the previous run already saw; the conflicts it deferred; and the originals waiting for a maintainer because they change workflows. Nothing open prints nothing.
Reads the [line's record](#the-lines-record-and-reports) first, as the other commands do, and exits 2 when it cannot read it or when it says the open batch is being closed, with a batch open or not. Then it reads the open batch pull request of the line, trusted as described below, and prints one line for each thing that waits on it: a batch open for three days or more, the shortest gap between two scheduled runs, so one the previous run already saw; the conflicts it deferred; and the originals waiting for a maintainer because they change workflows. Nothing open prints nothing.
With `--push` it writes the same into one comment on the batch pull request: the conflicts, each to be resolved as the [conflict task](../../docs/release/backport-tasks/resolve-conflict.md) describes, and the originals that wait because they change workflows.
The comment starts with `<!-- backport-batch:notify -->` and is one per pull request: a later run edits it in place rather than adding another, and once nothing waits it says so. Only a comment whose author the batch trusts counts as it, so one someone else wrote with the same first line is neither edited nor believed. It mentions `--mention`, an `org/team` or a login, with or without `@`, only while something waits. A failed batch CI is not among what it reports: the run has no trigger on the batch's CI finishing.
### report
Prints a report a batch pull request keeps: the one its body carries, or the one `--id` names. It reads the report comments the batch trusts and falls back to the body, recomputes the report's ID from what it says, and exits 2 when a trusted comment does not match its ID, when a report says anything a run does not write or is for another line than the pull request's, or when the pull request keeps no such report. A report comment counts only when its author is one the batch trusts with a record, so anyone else's comment, whatever ID it carries, is not read. It prints what the classification was made of, whether the pull request's head or body shows the report was published, which it calls unconfirmed when neither does, and one line per original with its category and what the report recorded about it; `--json` prints the report as stored. A report whose comment the run never got to post is printed from the body, with a note that the next `run --push` posts it, or, on a merged or closed batch, that it is kept in the body only. It writes nothing. It reads GitHub, and for a report that keeps drops also the clone's history, without fetching: each drop is bound to `main` as the record's drops are, over the heads of `main` and the line the report was made from, so a drop of an original the report classified has to name that original's merge, and any other a merge of its number on `main`'s first-parent line. A clone without those heads exits 2, asking for a fetch.
### lines
Prints the release lines the batch runs for, newest first: of the `release-X.Y` branches on `origin`, the newest that have a stable `vX.Y.Z` tag there, as many as `released-lines` in the policy block says, two when it says nothing, and every branch newer than the newest of them. The tags are read with `git ls-remote`; a tag with a pre-release suffix does not count. `run`, `check` and `fold` refuse any other line, and rule 6 holds each line to the next newer one among them.
@@ -94,10 +105,24 @@ Only the line's own commits count, the ones after the point where it branched fr
Titles and bodies are read from the merge commits on `main`, which hold them as a maintainer merged them; the live pull request stays editable by its author.
## The line's record and reports
The line's record is the `backport-batch` block of its newest batch pull request: the newest pull request against the line from a `backport-batch/<line>-<yyyymmdd>` branch of this repository, opened by an author the batch trusts as above, whether it is open, merged or closed. `run`, `check`, `fold` and `notify` find it in GitHub's list of the line's pull requests, newest first, reading at most ten pages of 100. A batch pull request a person opened without the block is passed over. A block whose author the batch does not trust, a bot's batch without its block, a block that cannot be read whole, a closed record newer than the open batch, or ten pages with no record stop the command with exit 2, naming the pull request, rather than fall back to an older record.
The record is written only while its pull request is open: a merged or closed batch pull request is never edited or commented on again. What it recorded keeps counting, so a conflict or a deferral a merged batch recorded still prints as a warning. A workflow original it listed as waiting is missing, since no open batch lists it. A run that picks nothing while no batch is open has nowhere to write: it prints the body, says that its classification is not retained, and exits 0.
The record is only as trustworthy as the body it sits in. Anyone with write access can edit any body, the bot's included, and neither the bot's authorship nor anything in the block proves who edited it last. A hand edit that leaves a readable record is believed; one that breaks it stops every command with exit 2, naming the pull request. A reason or a file name in the record counts only in the form a run writes it, one line with no control, zero-width or bidi character, so text someone typed into the body never reaches a terminal, a body or a comment as they typed it. When the newest batch pull request is deleted and no later one exists, nothing shows it was there: the older record governs, and what was recorded since is lost.
The record holds only what later commands act on. Every run that classifies the line also makes a report, one line of JSON in a `backport-batch-report` block: every original merged into `main` since the line branched in exactly one category, picked, missing, deferred with what it was deferred for, or skipped with the rule that skipped it, each drop the record keeps with the merge commit it is bound to, its reason and the batch pull request that recorded it, the commits on `main` that merged no pull request, the labels, and what the classification was made of: the heads of `main`, the line and the newer line, the policy, the batch head it describes, the tool's commit, the Actions run and the flags. Its ID is a hash of all that but the time and the run, so two runs that find the same make the same report, and it names no pull request, so a new batch's report is whole before GitHub numbers the pull request. The body carries the latest report beside the record, and a comment on the open batch pull request keeps each one: `<!-- backport-batch:report <id> -->` and the same block, folded away. A report already posted is not posted again, and the tool never edits or deletes such a comment, so a report the next run replaces in the body is still read back by `report`, also after the batch merged. A comment that looks like one but whose author the batch does not trust is not read. When a run stops after writing the body but before posting its comment, the next `run --push` on the open batch posts that report first, byte for byte; once the batch is closed nothing is written to it, and the report stays in its body. A run that picks nothing while no batch is open keeps no report.
A `--defer` that leaves the batch carrying nothing closes it, and writes everything while the pull request is still open: the report comment, then the body, whose record keeps the deferrals and says the batch is closing, with its number, branch and the head the rebuild started from. It then reads the pull request and the branch again, closes the pull request while both are at that head, and deletes the branch with `--force-with-lease` on it, printing `closed <url>`. The next batch tries what was deferred again. A close that stops part way is finished by the next `run --push`, before it does anything else: it closes and deletes when the pull request is open at the frozen head, deletes the branch when the pull request is closed and the branch still at that head, and goes on as usual once both are gone. A run that finishes a close stops there, with exit 0, or with exit 2 when it was asked for `--defer`, `--drop` or `--undrop`, which it does not apply. It never takes a head it did not freeze: with the branch moved, it stops with exit 2 and names both heads. `run` without `--push`, `check` and `fold` stop with exit 2 while the pull request is still open; once it is closed they go on, warning about a branch the close left, and `notify` says nothing. A closed pull request is never written to, so its record says it was closing for good, and a closed pull request whose branch is gone counts as closed.
One way for the branch to move is a new batch of the same day: it reuses the branch name, and if its push succeeds and opening its pull request fails, the closed batch is still the newest record while its branch now holds the new batch. The next run stops on it, naming the branch, and so does `--onto` with its head. Check what it carries before deleting it with `git push origin --delete backport-batch/<line>-<yyyymmdd>`: the next run picks again what the tool put there from the line, but not a commit someone pushed onto it, such as a resolved conflict. Separate writes to GitHub are not atomic: the re-reads and the leases refuse a race they see, but cannot prevent one.
## What CI verifies
The tool does not build or test anything. CI on the batch pull request runs the line's own build and tests against what was picked, and `verify` against its shape, and a maintainer reviews and merges it. A deferred conflict waits for the next batch unless someone resolves it, checks the resolution with `fold` and pushes it, and then CI runs again.
## Untrusted text
Titles, release notes and file names in the body come from whoever opened the originals. A title, a reason or a file name goes into a code span whose fence no backtick inside can close, and the release notes go into the `release-note` block with every run of three backticks or tildes defused, so GitHub acts on no mention, reference, link or HTML in them. Inside that block, in case a note is copied out of it, links lose any target outside this repository, and closing keywords, cross-repository references and mentions have their `@`, `#` and `://` swapped for fullwidth signs, which act on nothing however the backticks around them pair up. Zero-width, bidi and control characters are dropped first, and everything the tool prints passes the same filter, so nothing it relays can drive a terminal. Commit messages are read through `git cat-file --batch`, which gives each object's length, and end at their first NUL as `git log` shows them, so a message cannot pass for a second commit or hide a trailer from a reader of the log. Every value handed to git comes after `--end-of-options` or `--`, refs pass `git check-ref-format`, SHAs have to be 40 hex digits, and nothing runs through a shell.
Titles, release notes and file names in the body come from whoever opened the originals, and a reason from whoever ran `--defer`. A title, a reason or a file name goes into a code span whose fence no backtick inside can close, and the release notes go into the `release-note` block with every run of three backticks or tildes defused, so GitHub acts on no mention, reference, link or HTML in them. Inside that block, in case a note is copied out of it, links lose any target outside this repository, and closing keywords, cross-repository references and mentions have their `@`, `#` and `://` swapped for fullwidth signs, which act on nothing however the backticks around them pair up. The record and the report are one line of JSON each, whose encoding leaves no newline, `<`, `>` or `&` in them, so nothing in a file name or a reason ends their block or opens HTML, and a report comment names no one: numbers are bare, and its only text outside the block is the line, the date and the report's ID. Zero-width, bidi and control characters are dropped first, and everything the tool prints passes the same filter, so nothing it relays can drive a terminal. Commit messages are read through `git cat-file --batch`, which gives each object's length, and end at their first NUL as `git log` shows them, so a message cannot pass for a second commit or hide a trailer from a reader of the log. Every value handed to git comes after `--end-of-options` or `--`, refs pass `git check-ref-format`, SHAs have to be 40 hex digits, and nothing runs through a shell.
+345 -42
View File
@@ -75,8 +75,10 @@ func (a *app) run(args []string) int {
cont := fs.Bool("continue", false, "continue the open batch pull request, or start a batch when none is open")
dateArg := fs.String("date", "", "batch date, YYYY-MM-DD (default: today, UTC)")
deferArg := fs.String("defer", "", "originals to take out of the open batch, N[,N]; rebuilds it without them")
dropArg := fs.String("drop", "", "originals to drop from the line, N[,N]; recorded on the open batch, and taken off it")
undropArg := fs.String("undrop", "", "originals whose drop to withdraw, N[,N]")
noWorkflows := fs.Bool("no-workflow-files", false, "leave an original that changes "+workflowsDir+" to a maintainer, for a token that cannot push one")
reasonArg := fs.String("reason", "", "why --defer takes them out, recorded in the batch body")
reasonArg := fs.String("reason", "", "why --defer takes them out or --drop drops them, recorded in the batch body")
push := fs.Bool("push", false, "push the branch and open or update the draft batch PR")
if err := parse(fs, args); err != nil {
return 2
@@ -96,9 +98,30 @@ func (a *app) run(args []string) int {
return a.fail(fmt.Errorf("--onto: %w", err))
}
}
var deferred []int
reason := ""
var deferred, drops []int
reason, ops := "", 0
for _, op := range []string{*deferArg, *dropArg, *undropArg} {
if op != "" {
ops++
}
}
switch {
case ops > 1:
return a.fail(errors.New("--defer, --drop and --undrop each change the open batch pull request on their own; give one"))
case *dropArg != "" || *undropArg != "":
if *onto != "" || *cont || *dateArg != "" {
return a.fail(errors.New("--drop and --undrop change the open batch pull request; each takes no --onto, --continue or --date"))
}
if *undropArg != "" && *reasonArg != "" {
return a.fail(errors.New("--undrop takes no --reason"))
}
list := *dropArg + *undropArg
if drops, err = parseNumbers(map[bool]string{true: "--drop", false: "--undrop"}[*dropArg != ""], list); err != nil {
return a.fail(err)
}
if reason = capped(oneLine(clean(*reasonArg, false)), titleLimit); *dropArg != "" && reason == "" {
return a.fail(errors.New("--drop needs a --reason, which the line's record keeps"))
}
case *deferArg != "" || *reasonArg != "":
if deferred, reason, err = parseDefer(*deferArg, *reasonArg); err != nil {
return a.fail(err)
@@ -119,6 +142,12 @@ func (a *app) run(args []string) int {
if err != nil {
return a.fail(err)
}
if goOn, code := a.resumeClose(b, *push, deferred != nil || drops != nil); !goOn {
return code
}
if drops != nil {
return a.redrop(b, drops, reason, *undropArg != "", *push)
}
if deferred != nil {
return a.rebuild(b, deferred, reason, *push)
}
@@ -147,10 +176,8 @@ func (a *app) run(args []string) int {
// Continuing the open batch keeps out what was deferred from it by hand;
// a new batch tries those again.
held := map[int]deferredEntry{}
if carried != nil && b.pr != nil && b.pr.Head.Ref == branch {
if held, err = parseDeferred(b.pr.Body, l); err != nil {
return a.fail(fmt.Errorf("batch pull request #%d: %w", b.pr.Number, err))
}
if carried != nil && b.src.open && b.pr.Head.Ref == branch {
held = b.src.rec.deferred()
}
if err := a.warnReset(branch, start); err != nil {
return a.fail(err)
@@ -160,7 +187,12 @@ func (a *app) run(args []string) int {
}
r := newReport(b)
r.opts = runOptions{NoWorkflowFiles: *noWorkflows, Mode: modeNew}
// A drop is a decision about the line, not about one batch: a new batch
// keeps the ones the closed batch recorded.
r.dropped = b.src.rec.dropped()
if carried != nil {
r.opts.Mode = modeContinue
if r.stale, err = keptOut(b, carried); err != nil {
return a.fail(err)
}
@@ -218,12 +250,12 @@ func (a *app) run(args []string) int {
return a.finish(b, r, branch, date, *push, false)
}
// keptOut lists the originals with backport/skip that the batch picked before
// the label came: a run never takes a commit off the batch, --defer does.
// keptOut lists the originals with backport/skip, or dropped, that the batch
// still carries: a run never takes a commit off the batch, --defer does.
func keptOut(b *batch, carried *history) ([]*verdict, error) {
var out []*verdict
for _, v := range b.verdicts {
if v.label != labelSkip {
if v.rule != ruleSkipLabel && v.rule != ruleDropped {
continue
}
if err := b.load(v); err != nil {
@@ -266,12 +298,32 @@ func (a *app) finish(b *batch, r *report, branch string, date time.Time, push, r
if r.shapes, err = summarize(b, head, r.onBranch); err != nil {
return a.fail(err)
}
title := prTitle(b.line, date.Format(time.DateOnly))
if rebuilt && head == b.lineHead && b.pr != nil {
// Deferring everything the batch carries leaves nothing to push: the
// batch is closed, and its record says so first, with the head the
// close is leased on.
r.closing = &closing{PR: b.pr.Number, Branch: branch, Head: b.prHead}
r.opts.Mode = modeClose
}
r.built = builtFrom{Main: b.mainHead, Line: b.lineHead, Newer: b.newerHead, Policy: b.policyAt, Batch: head, Tool: a.tool,
Run: runURL(os.LookupEnv), At: a.now().UTC().Format(time.RFC3339), Options: r.opts}
doc, err := buildReport(b, r)
if err != nil {
return a.fail(err)
}
p := publication{branch: branch, head: head, title: prTitle(b.line, date.Format(time.DateOnly)), report: storedReport{doc: doc, raw: reportJSON(doc)}}
p.record = r.lineRecord(doc)
// A record the next command would refuse is never written.
if err := p.record.validate(b.line); err != nil {
return a.fail(fmt.Errorf("the record this run made %w", err))
}
r.blocks = recordBlock(p.record) + "\n\n" + reportBlock(p.report.raw)
template, err := lineTemplate(b.lineHead)
if err != nil {
return a.fail(err)
}
body, left := r.body(template)
p.body = body
fmt.Fprint(a.stdout, body)
skipped := 0
for _, items := range r.skipped {
@@ -279,29 +331,43 @@ func (a *app) finish(b *batch, r *report, branch string, date time.Time, push, r
}
for _, rule := range left {
for _, v := range r.skipped[rule] {
fmt.Fprintf(a.stderr, "skipped by rule %d: %s %s (%s)\n", rule, v.o.url(), terminalSafe(v.o.title), terminalSafe(v.reason))
fmt.Fprintf(a.stderr, "skipped by %s: %s %s (%s)\n", ruleName(rule), v.o.url(), terminalSafe(v.o.title), terminalSafe(v.reason))
}
}
for _, p := range r.waiting {
fmt.Fprintf(a.stderr, "waiting for a maintainer: %s %s changes %s\n", p.v.o.url(), terminalSafe(p.v.o.title), terminalSafe(strings.Join(p.files, ", ")))
}
fmt.Fprintf(a.stderr, "%s at %.12s: %d on the branch, %d deferred, %d waiting for a maintainer, %d skipped\ntitle: %s\n",
branch, head, len(r.onBranch), len(r.deferred)+len(r.held), len(r.waiting), skipped, title)
fmt.Fprintf(a.stderr, "%s at %.12s: %d on the branch, %d deferred, %d waiting for a maintainer, %d skipped\ntitle: %s\nreport: %s\n",
branch, head, len(r.onBranch), len(r.deferred)+len(r.held), len(r.waiting), skipped, p.title, doc.ID)
if !push {
return 0
}
if head == b.lineHead && b.pr == nil {
// Picking nothing is not a failure of the run; check still reports
// whatever the line lacks.
// whatever the line lacks. Merged and closed batches are never
// written, so there is nowhere to keep what the run found.
fmt.Fprintf(a.stderr, "%s carries nothing beyond %s; nothing to push\n", branch, b.line.branch())
fmt.Fprintf(a.stderr, "no batch pull request is open on %s; this run's classification is not retained\n", b.line.branch())
return 0
}
if err := a.publish(b, branch, head, title, body, rebuilt); err != nil {
if err := a.publish(b, p, rebuilt); err != nil {
return a.fail(err)
}
return 0
}
// lineRecord is the record a run leaves for the line: what later commands
// act on, with the full classification left to the report it names.
func (r *report) lineRecord(doc reportDoc) lineRecord {
built := doc.Built
dropped := slices.Clone(r.dropped)
if dropped == nil {
dropped = []droppedEntry{}
}
slices.SortFunc(dropped, func(a, b droppedEntry) int { return a.Original - b.Original })
return lineRecord{Version: 2, Line: doc.Line, Built: &built, Report: doc.ID, Closing: r.closing, Deferred: slices.Clone(doc.Primary.Deferred), Dropped: dropped, Stale: slices.Clone(doc.Overlays.Stale)}
}
// branchDate is the date a batch branch was opened under.
func branchDate(branch string) (time.Time, error) {
m := batchBranchRE.FindStringSubmatch(branch)
@@ -446,80 +512,312 @@ func lineTemplate(lineHead string) (string, error) {
return git("show", "--end-of-options", lineHead+":"+template)
}
// publication is what a run publishes: the branch at head, and the body that
// carries the record and the report.
type publication struct {
branch, head, title, body string
report storedReport
record lineRecord
}
// publish pushes the batch branch, then opens the draft batch pull request or
// updates the open one. A run pushes only a fast-forward of what the remote
// has. A rebuild replaces the branch, and only the head it was rebuilt from:
// the lease is that head, as ls-remote reads it now, so a commit pushed since
// is never dropped.
func (a *app) publish(b *batch, branch, head, title, body string, rebuilt bool) error {
if b.pr != nil && b.pr.Head.Ref != branch {
//
// The report goes up as a comment before the push and the body that names
// it, so no write leaves a body naming a report nobody can read; a new batch
// carries the report in the body it is opened with, and the comment follows.
// Everything is rendered and measured before the first write.
func (a *app) publish(b *batch, p publication, rebuilt bool) error {
if b.pr != nil && b.pr.Head.Ref != p.branch {
return fmt.Errorf("the open batch pull request #%d is on %s; continue it with --onto %s", b.pr.Number, b.pr.Head.Ref, b.prHead)
}
// A body without the block was written by hand, and the tool's would
// replace it whole, title included; such a batch is the maintainer's.
if b.pr != nil && !strings.Contains("\n"+strings.ReplaceAll(b.pr.Body, "\r\n", "\n")+"\n", "\n"+deferredFence+"\n") {
if b.pr != nil && !b.src.open {
return fmt.Errorf("batch pull request #%d has no %s block, so backport-batch did not write it; merge or close it, and the next run opens a batch of its own", b.pr.Number, deferredFence)
}
if head == b.lineHead {
if rebuilt {
return fmt.Errorf("without what is deferred, %s carries nothing beyond %s; close batch pull request #%d instead", branch, b.line.branch(), b.pr.Number)
if p.head == b.lineHead {
if rebuilt && p.record.Closing != nil {
return a.closeBatch(b, p)
}
return fmt.Errorf("%s carries nothing beyond %s; nothing to push", branch, b.line.branch())
return fmt.Errorf("%s carries nothing beyond %s; nothing to push", p.branch, b.line.branch())
}
heads, err := remoteBatchHeads(b.line)
if err != nil {
return err
}
pushed, onRemote := heads[branch]
pushed, onRemote := heads[p.branch]
lease := ""
switch {
case rebuilt:
if !onRemote || pushed != b.prHead {
return fmt.Errorf("%s on %s is at %.12s, not at %.12s, which the rebuild started from; run again", branch, remote, pushed, b.prHead)
return fmt.Errorf("%s on %s is at %.12s, not at %.12s, which the rebuild started from; run again", p.branch, remote, pushed, b.prHead)
}
lease = pushed
case onRemote && pushed != head:
case onRemote && pushed != p.head:
have, err := lookup(pushed)
if err != nil {
return err
}
descends := false
if have != "" {
if descends, err = isAncestor(pushed, head); err != nil {
if descends, err = isAncestor(pushed, p.head); err != nil {
return err
}
}
if !descends {
refusal := fmt.Sprintf("%s on %s is at %.12s, which %.12s does not descend from; refusing a non-fast-forward push", branch, remote, pushed, head)
refusal := fmt.Sprintf("%s on %s is at %.12s, which %.12s does not descend from; refusing a non-fast-forward push", p.branch, remote, pushed, p.head)
// A run that stopped between its push and opening the pull request
// leaves the branch, and the next run that day builds it anew.
if b.pr == nil {
switch c := b.src.carrier; {
case b.pr != nil:
case c != nil && !b.src.open && c.Head.Ref == p.branch:
// Continuing a batch that merged or closed would publish
// what nobody can merge any more.
refusal += fmt.Sprintf("; it is the branch of batch pull request #%d, which is %s; the next run picks again what the tool put on it, but not a commit someone pushed there, such as a resolved conflict: check what %.12s carries, then delete it with git push origin --delete %s and run again", c.Number, prState(c), pushed, p.branch)
default:
refusal += fmt.Sprintf("; no batch pull request is open on it, and backport-batch run --line %s --onto %s --push continues it and opens one", b.line.branch(), pushed)
}
return errors.New(refusal)
}
}
if pushed != head {
if _, err := git(pushArgs(branch, head, lease)...); err != nil {
if err := p.fits(); err != nil {
return err
}
if b.pr != nil {
if err := a.keepReports(b, &p); err != nil {
return err
}
}
f, err := os.CreateTemp("", "backport-batch-body-*.md")
if pushed != p.head {
if _, err := git(pushArgs(p.branch, p.head, lease)...); err != nil {
return err
}
}
name, err := writeTemp("backport-batch-body-*.md", p.body)
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(body); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
defer os.Remove(name)
if b.pr == nil {
return a.gh.createPull(b.line.branch(), branch, title, f.Name())
n, url, err := a.gh.createPull(b.line.branch(), p.branch, p.title, name)
if err != nil {
return err
}
fmt.Fprintf(a.stderr, "opened draft %s\n", url)
if err := a.postReport(n, p.report); err != nil {
return fmt.Errorf("posting report %.12s on #%d: %w; the body keeps it, and the next run --push posts it", p.report.doc.ID, n, err)
}
return nil
}
return a.gh.editPull(b.pr.Number, title, f.Name())
if err := a.stillOpen(b.pr.Number); err != nil {
return err
}
if err := a.gh.editPull(b.pr.Number, p.title, name); err != nil {
return err
}
fmt.Fprintf(a.stderr, "updated %s\n", pullURL(b.pr))
return nil
}
// fits says why GitHub would not take the comment or the body.
func (p *publication) fits() error {
if _, err := reportComment(p.report); err != nil {
return err
}
if len(p.body) > bodyLimit {
return fmt.Errorf("the body is %d bytes with only what it cannot leave out, over GitHub's %d; nothing is published", len(p.body), bodyLimit)
}
return nil
}
// keepReports makes sure the open batch's comments keep both the report its
// body carries now and the one this run made, in that order, each once.
// When this run's report is one already kept, the body carries the kept
// bytes, which differ only in when and in which job it was made.
func (a *app) keepReports(b *batch, p *publication) error {
pr := b.pr
posted, bad, err := findReports(a.gh, pr.Number, b.line)
if err != nil {
return err
}
for _, why := range bad {
a.warn("batch pull request #%d: %s; it does not count as a report", pr.Number, why)
}
earlier, err := bodyReport(pr.Body, b.line)
if err != nil {
return fmt.Errorf("batch pull request #%d: %w", pr.Number, err)
}
kept, ok := posted[p.report.doc.ID]
if !ok && earlier != nil && earlier.doc.ID == p.report.doc.ID {
kept, ok = *earlier, true
}
if ok {
p.body = strings.Replace(p.body, reportBlock(p.report.raw), reportBlock(kept.raw), 1)
p.report = kept
if err := p.fits(); err != nil {
return err
}
}
if earlier != nil {
if _, ok := posted[earlier.doc.ID]; !ok {
if err := a.postReport(pr.Number, *earlier); err != nil {
return fmt.Errorf("posting the earlier report %.12s on #%d: %w", earlier.doc.ID, pr.Number, err)
}
posted[earlier.doc.ID] = *earlier
}
}
if _, ok := posted[p.report.doc.ID]; !ok {
if err := a.postReport(pr.Number, p.report); err != nil {
return fmt.Errorf("posting report %.12s on #%d: %w", p.report.doc.ID, pr.Number, err)
}
}
return nil
}
// pullURL is where a pull request is on GitHub.
func pullURL(pr *pullRequest) string {
if pullURLRE.MatchString(pr.HTMLURL) {
return pr.HTMLURL
}
return fmt.Sprintf("%spull/%d", ourRepoURL, pr.Number)
}
// closeBatch closes the open batch that --defer left carrying nothing. Every
// write happens while it is open: the report, then the body whose record
// says it is closing and at which head. The close and the deletion of its
// branch then go only while both are still at that head.
func (a *app) closeBatch(b *batch, p publication) error {
c := *p.record.Closing
if err := p.fits(); err != nil {
return err
}
if err := a.keepReports(b, &p); err != nil {
return err
}
name, err := writeTemp("backport-batch-body-*.md", p.body)
if err != nil {
return err
}
defer os.Remove(name)
if err := a.stillOpen(c.PR); err != nil {
return err
}
if err := a.gh.editPull(c.PR, p.title, name); err != nil {
return fmt.Errorf("recording the close of #%d: %w", c.PR, err)
}
return a.finishClose(b.line, pullURL(b.pr), c)
}
// finishClose closes the batch pull request a record says is closing, and
// deletes its branch, each only while it is at the head the record froze.
func (a *app) finishClose(l line, url string, c closing) error {
pr, err := a.gh.pull(c.PR)
if err != nil {
return fmt.Errorf("closing #%d, reading it again: %w", c.PR, err)
}
if st := prState(&pr); st != "open" || pr.Head.SHA != c.Head {
return fmt.Errorf("closing #%d: it is %s at %.12s, not open at %.12s, which its record froze", c.PR, st, terminalSafe(pr.Head.SHA), c.Head)
}
heads, err := remoteBatchHeads(l)
if err != nil {
return err
}
if h := heads[c.Branch]; h != c.Head {
return fmt.Errorf("closing #%d: %s on %s is at %.12s, not at %.12s, which its record froze", c.PR, c.Branch, remote, h, c.Head)
}
if err := a.gh.closePull(c.PR); err != nil {
return fmt.Errorf("closing #%d: %w; backport-batch run --line %s --push finishes it", c.PR, err, l.branch())
}
if err := a.deleteBranch(l, c); err != nil {
return err
}
fmt.Fprintf(a.stderr, "closed %s: without what is deferred it carries nothing beyond %s; the next batch tries them again\n", url, l.branch())
return nil
}
// deleteBranch deletes the branch of a closed batch, leased on the head it
// was closed at: one someone pushed since is a person's to delete or keep.
func (a *app) deleteBranch(l line, c closing) error {
heads, err := remoteBatchHeads(l)
if err != nil {
return err
}
switch h, ok := heads[c.Branch]; {
case !ok:
return nil
case h != c.Head:
return movedBranch(c, h)
}
if _, err := git(deleteArgs(c.Branch, c.Head)...); err != nil {
return fmt.Errorf("deleting %s, left by the close of #%d: %w; backport-batch run --line %s --push deletes it", c.Branch, c.PR, err, l.branch())
}
return nil
}
// takenBranch is the refusal for a branch a close left that an open batch
// pull request now uses, whatever it holds, or nil.
func takenBranch(b *batch, c closing) error {
if b.pr == nil || b.pr.Head.Ref != c.Branch {
return nil
}
return fmt.Errorf("batch pull request #%d is open on %s, which the close of #%d left; the branch is that pull request's now, and the close leaves it alone", b.pr.Number, c.Branch, c.PR)
}
func movedBranch(c closing, h string) error {
return fmt.Errorf("batch pull request #%d was closed at %.12s, but %s is at %.12s now; the next run picks again what the tool put on it, but not a commit someone pushed there, such as a resolved conflict: check what %.12s carries, then delete it with git push origin --delete %s, and run again", c.PR, c.Head, c.Branch, h, h, c.Branch)
}
// resumeClose finishes a close the record says is under way, before a run
// does anything else, and only under --push. It says whether the run may go
// on: only once the batch is closed and its branch gone. A run that finishes
// one stops there, and fails when it was asked to change the batch.
func (a *app) resumeClose(b *batch, push, changing bool) (bool, int) {
if b.src.rec == nil || b.src.rec.Closing == nil {
return true, 0
}
c := *b.src.rec.Closing
heads, err := remoteBatchHeads(b.line)
if err != nil {
return false, a.fail(err)
}
h, onRemote := heads[c.Branch]
switch {
case b.src.open && h != c.Head:
return false, a.fail(fmt.Errorf("batch pull request #%d is being closed, but %s is at %.12s, not at %.12s, which its record froze; a person closes it, or pushes the branch back, by hand", c.PR, c.Branch, h, c.Head))
case b.src.open && !push:
return false, a.fail(fmt.Errorf("batch pull request #%d is being closed: closing it and deleting %s are left; backport-batch run --line %s --push finishes it", c.PR, c.Branch, b.line.branch()))
case b.src.open:
if err := a.finishClose(b.line, pullURL(b.src.carrier), c); err != nil {
return false, a.fail(err)
}
return false, a.finished(c, changing)
case !onRemote:
return true, 0
case takenBranch(b, c) != nil:
return false, a.fail(takenBranch(b, c))
case h != c.Head:
return false, a.fail(movedBranch(c, h))
case !push:
return false, a.fail(fmt.Errorf("batch pull request #%d was closed, but %s is still at %.12s; backport-batch run --line %s --push deletes it", c.PR, c.Branch, h, b.line.branch()))
}
if err := a.deleteBranch(b.line, c); err != nil {
return false, a.fail(err)
}
fmt.Fprintf(a.stderr, "deleted %s, which the close of #%d left\n", c.Branch, c.PR)
return false, a.finished(c, changing)
}
// finished is the exit of a run that finished a close: a change it was asked
// for needs an open batch pull request, and none is left to make it on.
func (a *app) finished(c closing, changing bool) int {
if !changing {
return 0
}
return a.fail(fmt.Errorf("finished the close of #%d and made no other change: what this run was asked to change needs an open batch pull request, which the next run opens", c.PR))
}
// pushArgs pushes head to branch: as a fast-forward, which the remote checks,
@@ -531,3 +829,8 @@ func pushArgs(branch, head, lease string) []string {
}
return append(args, "--end-of-options", remote, head+":refs/heads/"+branch)
}
// deleteArgs deletes branch, only while the remote has it at lease.
func deleteArgs(branch, lease string) []string {
return []string{"push", "--quiet", "--force-with-lease=refs/heads/" + branch + ":" + lease, "--end-of-options", remote, ":refs/heads/" + branch}
}
+14 -2
View File
@@ -258,9 +258,17 @@ func TestRunPush(t *testing.T) {
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
gh := &fakeGH{}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push"); code != 0 {
code, stdout, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push")
if code != 0 {
t.Fatalf("run --push = %d\n%s", code, stderr)
}
if !strings.Contains(stderr, "opened draft https://github.com/cozystack/cozystack/pull/90\n") {
t.Errorf("the run does not print the pull request it opened:\n%s", stderr)
}
l, _ := parseLine("release-1.1")
if _, err := parseDeferred(stdout, l); err != nil || !strings.HasPrefix(stdout, "## What this PR does") || strings.Contains(stdout, "https://github.com/cozystack/cozystack/pull/90") {
t.Errorf("stdout is no longer the body alone (%v):\n%s", err, stdout)
}
head := f.git("rev-parse", "HEAD")
if got := f.remoteHead(batchBranch1); got != head {
t.Errorf("remote %s = %q, want %s", batchBranch1, got, head)
@@ -272,9 +280,13 @@ func TestRunPush(t *testing.T) {
f.git("switch", "-q", "main")
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh.prs = []pullRequest{f.batchPR(90, "release-1.1", batchBranch1, gh.bodies[0])}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--onto", head, "--push"); code != 0 {
code, _, stderr = f.exec(gh, "run", "--line", "release-1.1", "--onto", head, "--push")
if code != 0 {
t.Fatalf("run --onto --push = %d\n%s", code, stderr)
}
if !strings.Contains(stderr, "updated https://github.com/cozystack/cozystack/pull/90\n") {
t.Errorf("the run does not print the pull request it updated:\n%s", stderr)
}
if got := f.subjects("origin/release-1.1", f.remoteHead(batchBranch1)); !slices.Equal(got, []string{"fix(app): five (#5)", "fix(app): six (#6)"}) {
t.Errorf("the pushed batch carries %q, want #5 then #6", got)
}
+5 -4
View File
@@ -24,7 +24,8 @@ import (
// A maintainer's run by hand continues the batch pull request the scheduled
// run opened as its bot, with nothing set: it edits that pull request rather
// than opening a second one for the line.
// than opening a second one for the line. A record another bot wrote is no
// record the batch trusts, and the run stops rather than reach past it.
func TestHandRunContinuesTheBotsBatch(t *testing.T) {
for _, tc := range []struct {
name, author string
@@ -44,12 +45,12 @@ func TestHandRunContinuesTheBotsBatch(t *testing.T) {
f.now = f.now.AddDate(0, 0, 3)
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push")
if code != 0 {
if tc.continued && code != 0 || !tc.continued && (code != 2 || !strings.Contains(stderr, "batch pull request #90 (open) carries a ```backport-batch block but was opened by \"renovate[bot]\"")) {
t.Fatalf("run --continue = %d\n%s", code, stderr)
}
continued := slices.Equal(gh.edited, []string{"#90 fix(backport): batch for release-1.1, 2026-10-26"}) && len(gh.created) == 1
if continued != tc.continued {
t.Errorf("edited %q, created %q; want the batch of %s continued=%v\n%s", gh.edited, gh.created, tc.author, tc.continued, stderr)
if continued != tc.continued || len(gh.created) != 1 {
t.Errorf("edited %q, created %q; want the batch of %s continued=%v and no second one\n%s", gh.edited, gh.created, tc.author, tc.continued, stderr)
}
if !tc.continued && !strings.Contains(stderr, "ignoring #90") {
t.Errorf("the run does not say it ignores #90:\n%s", stderr)
+51 -69
View File
@@ -190,25 +190,11 @@ type deferredRecord struct {
Deferred []deferredEntry `json:"deferred"`
}
// deferredBlock is the machine-readable list of what the batch defers. One
// line of JSON: encoding/json escapes newlines and <, > and &, so nothing in a
// file name or a reason can end the fence or open HTML. It leaves bidi and
// zero-width characters as they are, so file names go through clean first,
// as the reason does.
// deferredBlock is a version 1 record, which batches carried before records
// had a version. encoding/json leaves bidi and zero-width characters as they
// are, so file names go through clean first, as the reason does.
func deferredBlock(l line, conflicts, held, waiting []pick) string {
rec := deferredRecord{Line: l.branch(), Deferred: []deferredEntry{}}
for _, p := range conflicts {
files := cleanFiles(p.files[:min(len(p.files), filesKept)])
rec.Deferred = append(rec.Deferred, deferredEntry{Original: p.v.o.number, Commit: p.failed, Files: files, More: len(p.files) - len(files) + p.more})
}
for _, p := range held {
rec.Deferred = append(rec.Deferred, deferredEntry{Original: p.v.o.number, Files: []string{}, Reason: capped(oneLine(clean(p.reason, false)), titleLimit)})
}
for _, p := range waiting {
files := cleanFiles(p.files[:min(len(p.files), filesKept)])
rec.Deferred = append(rec.Deferred, deferredEntry{Original: p.v.o.number, Files: files, More: len(p.files) - len(files) + p.more, Workflows: true})
}
data, _ := json.Marshal(rec)
data, _ := json.Marshal(deferredRecord{Line: l.branch(), Deferred: deferredEntries(conflicts, held, waiting)})
return deferredFence + "\n" + string(data) + "\n```"
}
@@ -220,54 +206,13 @@ func cleanFiles(files []string) []string {
return out
}
// parseDeferred reads what a batch body defers: exactly one block, for this
// line, naming each original once.
// parseDeferred reads what a batch body's record defers.
func parseDeferred(body string, l line) (map[int]deferredEntry, error) {
var blocks []string
lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
for i := 0; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) != deferredFence {
continue
}
if i+2 >= len(lines) || strings.TrimSpace(lines[i+2]) != "```" {
return nil, fmt.Errorf("the %s block is not one line of JSON", deferredFence)
}
blocks = append(blocks, lines[i+1])
i += 2
rec, err := parseRecord(body, l)
if err != nil {
return nil, err
}
switch len(blocks) {
case 0:
return map[int]deferredEntry{}, nil
case 1:
default:
return nil, fmt.Errorf("the body holds %d %s blocks", len(blocks), deferredFence)
}
dec := json.NewDecoder(strings.NewReader(blocks[0]))
dec.DisallowUnknownFields()
var rec deferredRecord
if err := dec.Decode(&rec); err != nil {
return nil, fmt.Errorf("reading the %s block: %w", deferredFence, err)
}
if rec.Line != l.branch() {
return nil, fmt.Errorf("the %s block is for %q, not %s", deferredFence, rec.Line, l.branch())
}
out := map[int]deferredEntry{}
for _, d := range rec.Deferred {
if d.Original <= 0 {
return nil, fmt.Errorf("the %s block names original %d", deferredFence, d.Original)
}
if d.Commit != "" && checkSHA(d.Commit) != nil {
return nil, fmt.Errorf("the %s block names commit %q for #%d", deferredFence, d.Commit, d.Original)
}
if d.Workflows && (d.Commit != "" || d.Reason != "") {
return nil, fmt.Errorf("the %s block has #%d wait for a maintainer and defers it as well", deferredFence, d.Original)
}
if _, dup := out[d.Original]; dup {
return nil, fmt.Errorf("the %s block names #%d twice", deferredFence, d.Original)
}
out[d.Original] = d
}
return out, nil
return rec.deferred(), nil
}
// prTitle is built from the line and the date alone, never from carried text.
@@ -276,7 +221,7 @@ func prTitle(l line, date string) string {
}
// skipRules are the rules a skipped original is reported under, in order.
var skipRules = []int{ruleBeforeStart, ruleOnLine, ruleType, ruleBreaking, ruleTouches, ruleNewer}
var skipRules = []int{ruleBeforeStart, ruleOnLine, ruleType, ruleBreaking, ruleTouches, ruleNewer, ruleDropped}
// report is what one run put on the batch branch and what it left out.
type report struct {
@@ -295,6 +240,41 @@ type report struct {
start time.Time
// shapes says, for an original on the branch, what it brought.
shapes map[*verdict]string
// missing are originals the policy takes that the run neither tried nor
// found anywhere.
missing []*verdict
// opts is how the run was asked to classify, and built what it read.
opts runOptions
built builtFrom
// blocks are the record and the report the body ends with.
blocks string
// closing is the close of the open batch this run leaves carrying
// nothing.
closing *closing
// dropped are the maintainers' drops the record keeps.
dropped []droppedEntry
}
// ruleName is how output names what kept an original out: the policy's
// number for its own rules, and words for what the policy does not number.
func ruleName(rule int) string {
switch rule {
case ruleSkipLabel:
return labelSkip
case ruleBeforeStart:
return "rule 1, before the line's start"
case ruleDropped:
return "a maintainer's drop"
}
return "rule " + strconv.Itoa(rule)
}
// listTitle titles the list of what a rule skipped, a drop among them.
func (r *report) listTitle(rule int) string {
if rule == ruleDropped {
return "Dropped from " + codeSpan(r.line.branch()) + " by a maintainer"
}
return r.ruleTitle(rule)
}
func (r *report) ruleTitle(rule int) string {
@@ -382,7 +362,7 @@ func (r *report) what(details map[int]bool) string {
if !details[rule] {
left = ", too many to list here; the run's output lists them"
}
fmt.Fprintf(&b, "- %s: %d%s\n", r.ruleTitle(rule), len(items), left)
fmt.Fprintf(&b, "- %s: %d%s\n", r.listTitle(rule), len(items), left)
}
if r.other > 0 {
fmt.Fprintf(&b, "- Commits on `main` that merged no pull request: %d\n", r.other)
@@ -390,20 +370,22 @@ func (r *report) what(details map[int]bool) string {
for _, rule := range skipRules {
items := r.skipped[rule]
if len(items) > 0 && details[rule] {
fmt.Fprintf(&b, "\n<details><summary>%s</summary>\n\n", r.ruleTitle(rule))
fmt.Fprintf(&b, "\n<details><summary>%s</summary>\n\n", r.listTitle(rule))
for _, v := range items {
fmt.Fprintf(&b, "- %s %s, %s\n", prLink(v.o.number), titleSpan(v.o.title), titleSpan(v.reason))
}
b.WriteString("\n</details>\n")
}
}
b.WriteString("\n" + deferredBlock(r.line, r.deferred, r.held, r.waiting) + "\n")
b.WriteString("\n" + r.blocks + "\n")
return b.String()
}
// outcome is what became of an original a label overrode.
func (r *report) outcome(v *verdict) string {
switch {
case slices.Contains(r.stale, v) && v.rule == ruleDropped:
return "dropped, but still on this branch; take it out with `backport-batch run --defer`"
case slices.Contains(r.stale, v):
return "still on this branch, picked before the label; take it out with `backport-batch run --defer`"
case v.label == labelSkip:
@@ -417,7 +399,7 @@ func (r *report) outcome(v *verdict) string {
case slices.ContainsFunc(r.waiting, func(p pick) bool { return p.v == v }):
return "waiting for a maintainer: it changes " + codeSpan(workflowsDir)
case v.rule != 0:
return "skipped by rule " + strconv.Itoa(v.rule) + ", " + titleSpan(v.reason)
return "skipped by " + ruleName(v.rule) + ", " + titleSpan(v.reason)
}
return "not picked"
}
+17 -3
View File
@@ -286,7 +286,7 @@ func TestBodyFollowsTheTemplate(t *testing.T) {
l, _ := parseLine("release-1.6")
template := "<!-- tips -->\n\n## What this PR does\n\n\n### Screenshots\n\n<!-- shots -->\n\n### Downstream repositories\n\n- [ ] No downstream repository is affected by this change\n\n### Release note\n\n```release-note\n\n```\n"
o := &original{number: 7, title: "fix(x): y", body: "```release-note\nfix(x): note\n```"}
r := &report{line: l, onBranch: []*verdict{{o: o}}, skipped: map[int][]*verdict{}}
r := &report{line: l, onBranch: []*verdict{{o: o}}, skipped: map[int][]*verdict{}, blocks: deferredBlock(l, nil, nil, nil)}
body, _ := r.body(template)
for _, want := range []string{"## What this PR does\n\nBackports to", "- [#7](https://github.com/cozystack/cozystack/pull/7) `fix(x): y`", "### Downstream repositories\n\n- [ ] No downstream", "```release-note\nfix(x): note\n```", deferredFence} {
if !strings.Contains(body, want) {
@@ -305,7 +305,7 @@ func TestBodyFollowsTheTemplate(t *testing.T) {
for i := range 40 {
many = append(many, &verdict{o: &original{number: i + 1, title: "fix(x): y", body: "```release-note\n" + long + "\n```"}})
}
r = &report{line: l, onBranch: many, skipped: map[int][]*verdict{ruleType: many}}
r = &report{line: l, onBranch: many, skipped: map[int][]*verdict{ruleType: many}, blocks: deferredBlock(l, nil, nil, nil)}
if body, _ := r.body(template); len(body) > bodyLimit || !strings.Contains(body, "truncated") || !strings.Contains(body, deferredFence) {
t.Errorf("a body over the limit is %d bytes (limit %d), truncated=%v", len(body), bodyLimit, strings.Contains(body, "truncated"))
}
@@ -322,7 +322,7 @@ func TestBodyFollowsTheTemplate(t *testing.T) {
for i := range 80 {
conflict.files = append(conflict.files, fmt.Sprintf("chart/f%02d.yaml", i))
}
r = &report{line: l, onBranch: many[:1], deferred: []pick{conflict}, skipped: map[int][]*verdict{ruleType: lots, ruleOnLine: few}}
r = &report{line: l, onBranch: many[:1], deferred: []pick{conflict}, skipped: map[int][]*verdict{ruleType: lots, ruleOnLine: few}, blocks: deferredBlock(l, []pick{conflict}, nil, nil)}
body, left := r.body(template)
if !slices.Equal(left, []int{ruleType}) {
t.Errorf("left out the lists of rules %v, want only rule 2", left)
@@ -366,3 +366,17 @@ func TestBodyLinksTaskPagesThatExist(t *testing.T) {
}
}
}
// A body that what it has to carry pushes over GitHub's limit is refused
// before anything is written, the replay of an earlier report included,
// although the report alone would fit a comment.
func TestBodyRefusesMandatoryContentOverTheLimit(t *testing.T) {
f, gh, head := bigWaits(t, 4)
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--no-workflow-files", "--push")
if code != 2 || !strings.Contains(stderr, "with only what it cannot leave out, over GitHub's 65536") {
t.Errorf("run --push = %d, want a refusal of the body:\n%s", code, stderr)
}
if f.remoteHead(batchBranch1) != head || len(gh.issueComments) != 0 || len(gh.edited) != 0 {
t.Errorf("the refused run moved the batch to %.12s, wrote %d comments and edited %q", f.remoteHead(batchBranch1), len(gh.issueComments), gh.edited)
}
}
+135 -38
View File
@@ -18,6 +18,7 @@ package main
import (
"fmt"
"slices"
"strconv"
)
@@ -46,59 +47,149 @@ func (a *app) check(args []string) int {
if err != nil {
return a.fail(err)
}
deferred := map[int]deferredEntry{}
if b.pr != nil {
if deferred, err = parseDeferred(b.pr.Body, l); err != nil {
return a.fail(fmt.Errorf("batch pull request #%d: %w", b.pr.Number, err))
if err := a.warnLeftBranch(b); err != nil {
return a.fail(err)
}
disp, err := b.dispositions(*cut)
if err != nil {
return a.fail(err)
}
var missing, failing []string
for _, v := range b.verdicts {
d, ok := disp[v]
switch {
case !ok:
case d.kind == outcomeMissing:
missing = append(missing, d.text)
case d.kind == outcomeUnmerged || d.kind == outcomeWaiting:
failing = append(failing, d.text)
case d.kind == outcomeWarning:
a.warn("%s", d.text)
case d.kind == outcomeNote:
fmt.Fprintf(a.stderr, "note: %s\n", d.text)
}
}
var missing, unmerged, waiting []*verdict
for _, v := range b.qualifying() {
e, isDeferred := deferred[v.o.number]
switch {
case v.onBatch != "":
if *cut {
unmerged = append(unmerged, v)
}
case isDeferred && e.Workflows && *cut:
waiting = append(waiting, v)
case isDeferred && e.Workflows:
a.warn("waiting on #%d for a maintainer, since it changes %s: %s %s", b.pr.Number, workflowsDir, v.o.url(), terminalSafe(v.o.title))
case isDeferred && e.Reason != "":
a.warn("deferred on #%d to the next batch: %s %s (%s)", b.pr.Number, v.o.url(), terminalSafe(v.o.title), terminalSafe(e.Reason))
case isDeferred:
a.warn("deferred conflict on #%d: %s %s", b.pr.Number, v.o.url(), terminalSafe(v.o.title))
default:
missing = append(missing, v)
if len(missing)+len(failing) == 0 {
if *cut {
fmt.Fprintf(a.stdout, "%s: no change the policy takes is missing before the cut\n", l.branch())
} else {
fmt.Fprintf(a.stdout, "%s: every change the policy takes is on the line or on the batch\n", l.branch())
}
return 0
}
where := "and no batch pull request is open"
if b.pr != nil {
where = "nor on batch pull request #" + strconv.Itoa(b.pr.Number)
}
switch {
case len(missing) == 0 && len(unmerged) == 0 && len(waiting) == 0 && *cut:
fmt.Fprintf(a.stdout, "%s: every change the policy takes is on the line\n", l.branch())
return 0
case len(missing) == 0 && len(unmerged) == 0:
fmt.Fprintf(a.stdout, "%s: every change the policy takes is on the line or on the batch\n", l.branch())
return 0
}
if len(missing) > 0 {
fmt.Fprintf(a.stdout, "%s: %d changes the policy takes are not on the line, %s:\n", l.branch(), len(missing), where)
for _, v := range missing {
fmt.Fprintf(a.stdout, " %s %s\n", v.o.url(), terminalSafe(v.o.title))
for _, text := range missing {
fmt.Fprintf(a.stdout, " %s\n", text)
}
}
for _, v := range unmerged {
fmt.Fprintf(a.stdout, "%s: #%d is only on open batch PR #%d, merge it before the cut: %s %s\n", l.branch(), v.o.number, b.pr.Number, v.o.url(), terminalSafe(v.o.title))
}
for _, v := range waiting {
fmt.Fprintf(a.stdout, "%s: #%d changes %s and waits on batch PR #%d for a maintainer, bring it over or defer it before the cut: %s %s\n", l.branch(), v.o.number, workflowsDir, b.pr.Number, v.o.url(), terminalSafe(v.o.title))
for _, text := range failing {
fmt.Fprintf(a.stdout, "%s: %s\n", l.branch(), text)
}
return 1
}
// warnLeftBranch names the branch a closed batch's close left behind.
func (a *app) warnLeftBranch(b *batch) error {
if b.src.open || b.src.rec == nil || b.src.rec.Closing == nil {
return nil
}
c := b.src.rec.Closing
heads, err := remoteBatchHeads(b.line)
if err != nil {
return err
}
switch h, ok := heads[c.Branch]; {
case !ok:
case takenBranch(b, *c) != nil:
a.warn("%v", takenBranch(b, *c))
case h == c.Head:
a.warn("batch pull request #%d is closed, but its branch %s is still at %.12s; backport-batch run --line %s --push deletes it", c.PR, c.Branch, h, b.line.branch())
default:
a.warn("%v", movedBranch(*c, h))
}
return nil
}
// outcome is what check makes of one original.
type outcome int
const (
// outcomeMissing fails check and the cut: nothing accounts for an
// original the line lacks.
outcomeMissing outcome = iota + 1
// outcomeUnmerged and outcomeWaiting fail the cut only: the cut would not
// carry what is only on the open batch, nor what waits there.
outcomeUnmerged
outcomeWaiting
outcomeWarning
outcomeNote
)
type disposition struct {
kind outcome
text string
}
// dispositions says what check reports about each original that needs a line
// of its own; the others pass in silence. The text is what follows the line's
// name, or the original alone for one that is missing.
func (b *batch) dispositions(cut bool) (map[*verdict]disposition, error) {
if c := b.src.closingOpen(); c != nil {
return nil, fmt.Errorf("batch pull request #%d is being closed; backport-batch run --line %s --push finishes it", c.PR, b.line.branch())
}
var stale []*verdict
if b.pr != nil {
var err error
if stale, err = keptOut(b, b.picks); err != nil {
return nil, err
}
}
// A record a closed batch left still says what a maintainer decided, but
// nothing waits on a batch that is gone.
deferred, on := b.src.rec.deferred(), ""
if c := b.src.carrier; c != nil {
on = fmt.Sprintf("#%d", c.Number)
if !b.src.open {
on += " (" + prState(c) + ")"
}
}
out := map[*verdict]disposition{}
for _, v := range b.verdicts {
named := v.o.url() + " " + terminalSafe(v.o.title)
e, isDeferred := deferred[v.o.number]
switch {
case slices.Contains(stale, v):
out[v] = disposition{outcomeWarning, fmt.Sprintf("#%d is still on batch #%d; take it out with backport-batch run --defer: %s", v.o.number, b.pr.Number, named)}
case v.rule == ruleDropped:
d := b.drops[v.o.number]
out[v] = disposition{outcomeNote, fmt.Sprintf("#%d is dropped from %s on #%d (%s): %s", v.o.number, b.line.branch(), d.On, terminalSafe(d.Reason), named)}
case v.rule != 0:
case v.onBatch != "":
if cut {
out[v] = disposition{outcomeUnmerged, fmt.Sprintf("#%d is only on open batch PR #%d, merge it before the cut: %s", v.o.number, b.pr.Number, named)}
}
case isDeferred && e.Workflows && !b.src.open:
out[v] = disposition{outcomeMissing, named}
case isDeferred && e.Workflows && cut:
out[v] = disposition{outcomeWaiting, fmt.Sprintf("#%d changes %s and waits on batch PR %s for a maintainer, bring it over or defer it before the cut: %s", v.o.number, workflowsDir, on, named)}
case isDeferred && e.Workflows:
out[v] = disposition{outcomeWarning, fmt.Sprintf("waiting on %s for a maintainer, since it changes %s: %s", on, workflowsDir, named)}
case isDeferred && e.Reason != "":
out[v] = disposition{outcomeWarning, fmt.Sprintf("deferred on %s to the next batch: %s (%s)", on, named, terminalSafe(e.Reason))}
case isDeferred:
out[v] = disposition{outcomeWarning, fmt.Sprintf("deferred conflict on %s: %s", on, named)}
default:
out[v] = disposition{outcomeMissing, named}
}
}
return out, nil
}
// terminalSafe keeps a title from carrying escape sequences into a terminal.
func terminalSafe(s string) string { return capped(oneLine(clean(s, false)), titleLimit) }
@@ -130,6 +221,12 @@ func (a *app) fold(args []string) int {
if err != nil {
return a.fail(err)
}
if c := b.src.closingOpen(); c != nil {
return a.fail(fmt.Errorf("batch pull request #%d is being closed; backport-batch run --line %s --push finishes it", c.PR, l.branch()))
}
if err := a.warnLeftBranch(b); err != nil {
return a.fail(err)
}
why, err := refuseFold(b, *number, *commit)
if err != nil {
return a.fail(err)
@@ -156,7 +253,7 @@ func refuseFold(b *batch, n int, commit string) (string, error) {
case v.rule == ruleSkipLabel:
return fmt.Sprintf("#%d carries %s", n, labelSkip), nil
case v.rule != 0:
return fmt.Sprintf("#%d is not one the batch takes: rule %d, %s", n, v.rule, v.reason), nil
return fmt.Sprintf("#%d is not one the batch takes: %s, %s", n, ruleName(v.rule), terminalSafe(v.reason)), nil
case b.pr == nil:
return fmt.Sprintf("no batch pull request is open on %s", b.line.branch()), nil
case v.onBatch != "":
+225 -1
View File
@@ -17,6 +17,7 @@ limitations under the License.
package main
import (
"regexp"
"strings"
"testing"
"unicode"
@@ -140,12 +141,22 @@ func TestCheck(t *testing.T) {
{
name: "a batch opened by a member with read access claims nothing",
arrange: func(s *state) {
batch(s, "body\n\n"+deferred6+"\n", s.mc5)
batch(s, "body\n\n", s.mc5)
s.gh.perms = map[string]string{"maintainer": "read"}
},
code: 1,
listed: []string{"/pull/5 ", "/pull/6 "},
},
{
// An older record is never taken in place of a newer one nobody
// can vouch for.
name: "a record by a member with read access stops it",
arrange: func(s *state) {
batch(s, "body\n\n"+deferred6+"\n", s.mc5)
s.gh.perms = map[string]string{"maintainer": "read"}
},
code: 2,
},
{
name: "a deferred list for another line",
arrange: func(s *state) { batch(s, strings.Replace(deferred6, "release-1.1", "release-1.0", 1), s.mc5) },
@@ -186,6 +197,219 @@ func TestCheck(t *testing.T) {
}
}
// Every category an original can be in, through the commands that put it
// there, with what check and check --cut make of it: the exit code, and the
// warning or note on stderr that leaves it 0.
func TestCheckDispositions(t *testing.T) {
plain := func(t *testing.T) *fixture {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
return f
}
// conflicting has #5 conflict on the line, beside #6, which picks.
conflicting := func(t *testing.T) *fixture {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only five", map[string]string{"app/five.txt": "line\n"})
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
return f
}
must := func(t *testing.T, f *fixture, gh *fakeGH, args ...string) {
t.Helper()
if code, _, stderr := f.exec(gh, args...); code != 0 {
t.Fatalf("%q = %d\n%s", args, code, stderr)
}
f.git("switch", "-q", "main")
if len(gh.bodies) > 0 {
reopen(f, gh)
}
}
sixOnLine := func(f *fixture) { pickOnLine(f, 6, map[string]string{"app/six.txt": "6\n"}) }
cases := []struct {
name string
arrange func(t *testing.T) (*fixture, *fakeGH)
line string
check int
cut int
checkErr string // on stderr from check
cutOut string // on stdout from check --cut
cutErr string // on stderr from check --cut
notCutOut string
}{
{
name: "missing",
arrange: func(t *testing.T) (*fixture, *fakeGH) { return plain(t), &fakeGH{} },
check: 1, cut: 1, cutOut: "/pull/5 ",
},
{
name: "carried",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f := plain(t)
pickOnLine(f, 5, map[string]string{"app/five.txt": "5\n"})
sixOnLine(f)
return f, &fakeGH{}
},
check: 0, cut: 0, notCutOut: "/pull/5",
},
{
name: "picked, on the open batch",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := plain(t), &fakeGH{}
openBatch(t, f, gh)
return f, gh
},
check: 0, cut: 1, cutOut: "#5 is only on open batch PR #90",
},
{
name: "deferred conflict, open record",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := conflicting(t), &fakeGH{}
openBatch(t, f, gh)
sixOnLine(f)
return f, gh
},
check: 0, cut: 0, checkErr: "warning: deferred conflict on #90: https://github.com/cozystack/cozystack/pull/5 ", cutErr: "warning: deferred conflict on #90",
},
{
name: "deferred with a reason, open record",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := plain(t), &fakeGH{}
openBatch(t, f, gh)
must(t, f, gh, "run", "--line", "release-1.1", "--defer", "5", "--reason", "fails the app e2e", "--push")
sixOnLine(f)
return f, gh
},
check: 0, cut: 0, checkErr: "warning: deferred on #90 to the next batch: https://github.com/cozystack/cozystack/pull/5 ", cutErr: "(fails the app e2e)",
},
{
name: "workflow wait, open record, beside a pick",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := workflowWorld(t), &fakeGH{}
must(t, f, gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push")
return f, gh
},
check: 0, cut: 1, checkErr: "warning: waiting on #90 for a maintainer", cutOut: "#5 changes .github/workflows/ and waits on batch PR #90",
},
{
name: "workflow wait, open record, alone",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := workflowWorld(t), &fakeGH{}
must(t, f, gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push")
pickOnLine(f, 6, map[string]string{"app/six.txt": "6\n", ".github/renovate.json": "{}\n"})
return f, gh
},
check: 0, cut: 1, checkErr: "warning: waiting on #90 for a maintainer", cutOut: "#5 changes .github/workflows/ and waits on batch PR #90",
},
{
name: "backport/skip, not picked",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f := plain(t)
sixOnLine(f)
return f, &fakeGH{labels: map[string][]int{labelSkip: {5}}}
},
check: 0, cut: 0, notCutOut: "/pull/5",
},
{
name: "backport/skip after the pick, still on the open batch",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f, gh := plain(t), &fakeGH{}
openBatch(t, f, gh)
sixOnLine(f)
gh.labels = map[string][]int{labelSkip: {5}}
return f, gh
},
check: 0, cut: 0, checkErr: "warning: #5 is still on batch #90; take it out with backport-batch run --defer", cutErr: "still on batch #90",
},
{
name: "rule 6",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
return f, &fakeGH{}
},
line: "release-1.0",
check: 0, cut: 0, notCutOut: "/pull/5",
},
{
name: "rules 2 to 4",
arrange: func(t *testing.T) (*fixture, *fakeGH) {
f := newFixture(t)
f.lines()
f.pr(5, "feat(app): five", "", map[string]string{"app/five.txt": "5\n"})
return f, &fakeGH{}
},
check: 0, cut: 0, notCutOut: "/pull/5",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, gh := tc.arrange(t)
line := tc.line
if line == "" {
line = "release-1.1"
}
code, stdout, stderr := f.exec(gh, "check", "--line", line)
if code != tc.check || !strings.Contains(stderr, tc.checkErr) {
t.Errorf("check = %d, want %d with %q\nstdout:\n%s\nstderr:\n%s", code, tc.check, tc.checkErr, stdout, stderr)
}
code, stdout, stderr = f.exec(gh, "check", "--line", line, "--cut")
if code != tc.cut || !strings.Contains(stdout, tc.cutOut) || !strings.Contains(stderr, tc.cutErr) {
t.Errorf("check --cut = %d, want %d with %q, %q\nstdout:\n%s\nstderr:\n%s", code, tc.cut, tc.cutOut, tc.cutErr, stdout, stderr)
}
if tc.notCutOut != "" && strings.Contains(stdout, tc.notCutOut) {
t.Errorf("check --cut lists %q:\n%s", tc.notCutOut, stdout)
}
if tc.cut == 0 && !strings.Contains(stdout, "no change the policy takes is missing before the cut") {
t.Errorf("check --cut passes without saying so:\n%s", stdout)
}
})
}
}
// Output names a rule as the policy does: the numbers the tool keeps for
// what is no rule of the policy never reach a reader.
func TestOutputNamesNoInternalRule(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
f.setPolicy("release-1.1: 2099-01-01\n")
gh := &fakeGH{labels: map[string][]int{labelInclude: {6}}}
head := openBatch(t, f, gh)
internal := regexp.MustCompile(`rule 1[0-9][0-9]`)
for _, args := range [][]string{
{"fold", "--line", "release-1.1", "--original", "5", "--commit", head},
{"run", "--line", "release-1.1", "--defer", "5", "--reason", "x"},
} {
code, stdout, stderr := f.exec(gh, args...)
f.git("switch", "-q", "main")
if code == 0 || !strings.Contains(stdout+stderr, "#5 is not one the batch takes: rule 1, before the line's start, merged ") {
t.Errorf("%s = %d, want a refusal naming rule 1:\n%s%s", args[0], code, stdout, stderr)
}
if m := internal.FindString(stdout + stderr); m != "" {
t.Errorf("%s prints %q:\n%s%s", args[0], m, stdout, stderr)
}
}
}
// A refusal fold prints is one line with no control sequence, whatever the
// reason it repeats says: a drop's reason comes from a pull request body, a
// rule 4 reason from file names an original's author chose.
func TestFoldRefusalPrintsNothingThatDrivesATerminal(t *testing.T) {
l, _ := parseLine("release-1.1")
for _, reason := range []string{"dropped on #90: x\n\x1b[2Jrefused: forged", "touches \"app/a\x1b]0;x\x07\nok\""} {
b := &batch{line: l, verdicts: []*verdict{{o: &original{number: 5}, rule: ruleDropped, reason: reason}}}
why, err := refuseFold(b, 5, strings.Repeat("a", 40))
if err != nil || why == "" || strings.ContainsFunc(why, func(r rune) bool { return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) }) {
t.Errorf("refuseFold = %q, %v; want one clean line", why, err)
}
}
}
type errString string
func (e errString) Error() string { return string(e) }
+118
View File
@@ -0,0 +1,118 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"strings"
"testing"
)
// pickOnLine lands original n on release-1.1 as a maintainer's pick of its
// merge commit, with the -x line git appends, so the line carries it.
func pickOnLine(f *fixture, n int, files map[string]string) {
f.t.Helper()
mc, _, _ := strings.Cut(f.git("log", "--first-parent", "--format=%H", "--grep", fmt.Sprintf("(#%d)$", n), "main"), "\n")
f.onLine("release-1.1", fmt.Sprintf("fix: original %d\n\n(cherry picked from commit %s)", n, mc), files)
}
// A workflow original that waits on the open batch fails the cut, also when
// it is all the batch has left: the pick beside it was labelled
// backport/skip after the pick, or reached the line by hand.
func TestCutFailsWhenOnlyAWorkflowWaitIsLeft(t *testing.T) {
for _, tc := range []struct {
name string
leave func(f *fixture, gh *fakeGH)
}{
{"skip after the pick", func(_ *fixture, gh *fakeGH) { gh.labels = map[string][]int{labelSkip: {6}} }},
{"picked onto the line by hand", func(f *fixture, _ *fakeGH) {
pickOnLine(f, 6, map[string]string{"app/six.txt": "6\n", ".github/renovate.json": "{}\n"})
}},
} {
t.Run(tc.name, func(t *testing.T) {
f := workflowWorld(t)
gh := &fakeGH{}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push"); code != 0 {
t.Fatalf("run --push = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
reopen(f, gh)
tc.leave(f, gh)
code, stdout, stderr := f.exec(gh, "check", "--line", "release-1.1", "--cut")
if code != 1 || !strings.Contains(stdout, "#5 changes .github/workflows/ and waits on batch PR #90 for a maintainer") {
t.Errorf("check --cut = %d, want 1 naming #5:\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr)
}
if strings.Contains(stdout, "on the line or on the batch") {
t.Errorf("check --cut passes the line while #5 waits:\n%s", stdout)
}
})
}
}
// A conflict the batch deferred is still deferred once that batch merged:
// the next run picks nothing, opens nothing and writes nothing to the merged
// pull request, and check reads the merged batch's record.
func TestCheckAfterTheBatchMerged(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only five", map[string]string{"app/five.txt": "line\n"})
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
mergeBatch(f, gh)
written := len(gh.created) + len(gh.edited) + len(gh.issueComments)
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push"); code != 0 {
t.Fatalf("run --continue --push = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
if got := len(gh.created) + len(gh.edited) + len(gh.issueComments); got != written || f.remoteHead(batchBranch1) != "" {
t.Errorf("the run picked nothing and still wrote: created %q, edited %q", gh.created, gh.edited)
}
for _, cut := range []bool{false, true} {
args := []string{"check", "--line", "release-1.1"}
if cut {
args = append(args, "--cut")
}
code, stdout, stderr := f.exec(gh, args...)
if code != 0 || !strings.Contains(stderr, "warning: deferred conflict on #90 (merged): https://github.com/cozystack/cozystack/pull/5 ") {
t.Errorf("%q = %d, want 0 with #5 a deferred conflict:\nstdout:\n%s\nstderr:\n%s", args, code, stdout, stderr)
}
}
}
// A workflow original waits only while a batch is open to list it: once the
// batch that listed it merged, it is missing.
func TestWorkflowWaitAfterTheBatchMergedIsMissing(t *testing.T) {
f := workflowWorld(t)
gh := &fakeGH{}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push"); code != 0 {
t.Fatalf("run --push = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
reopen(f, gh)
mergeBatch(f, gh)
for _, cut := range []bool{false, true} {
args := []string{"check", "--line", "release-1.1"}
if cut {
args = append(args, "--cut")
}
if code, stdout, stderr := f.exec(gh, args...); code != 1 || !strings.Contains(stdout, "/pull/5 ") {
t.Errorf("%q = %d, want 1 with #5 missing:\nstdout:\n%s\nstderr:\n%s", args, code, stdout, stderr)
}
}
}
+51 -17
View File
@@ -27,23 +27,29 @@ import (
// parseDefer reads --defer N[,N] and the --reason that has to come with it.
func parseDefer(list, reason string) ([]int, string, error) {
if list == "" {
return nil, "", errors.New("--reason goes with --defer")
return nil, "", errors.New("--reason goes with --defer or --drop")
}
reason = capped(oneLine(clean(reason, false)), titleLimit)
if reason == "" {
return nil, "", errors.New("--defer needs a --reason, which the batch body records")
}
numbers, err := parseNumbers("--defer", list)
return numbers, reason, err
}
// parseNumbers reads the N[,N] list a flag takes, each once.
func parseNumbers(flag, list string) ([]int, error) {
var numbers []int
for _, s := range strings.Split(list, ",") {
n, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(s), "#"))
if err != nil || n <= 0 {
return nil, "", fmt.Errorf("--defer %q is not a list of pull request numbers", list)
return nil, fmt.Errorf("%s %q is not a list of pull request numbers", flag, list)
}
if !slices.Contains(numbers, n) {
numbers = append(numbers, n)
}
}
return numbers, reason, nil
return numbers, nil
}
// rebuild takes the originals in numbers out of the open batch: it builds the
@@ -54,17 +60,8 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
if b.pr == nil {
return a.fail(fmt.Errorf("no batch pull request is open on %s; --defer rebuilds the open one", b.line.branch()))
}
branch := b.pr.Head.Ref
date, err := branchDate(branch)
if err != nil {
return a.fail(err)
}
recorded, err := parseDeferred(b.pr.Body, b.line)
if err != nil {
return a.fail(fmt.Errorf("batch pull request #%d: %w", b.pr.Number, err))
}
hold := map[int]string{}
for n, e := range recorded {
for n, e := range b.recorded() {
if e.Reason != "" {
hold[n] = e.Reason
}
@@ -82,11 +79,19 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
case v == nil:
return a.fail(fmt.Errorf("#%d did not merge into main after %s branched", n, b.line.branch()))
case v.rule != 0 && !slices.Contains(stale, v):
return a.fail(fmt.Errorf("#%d is not one the batch takes: rule %d, %s", n, v.rule, terminalSafe(v.reason)))
return a.fail(fmt.Errorf("#%d is not one the batch takes: %s, %s", n, ruleName(v.rule), terminalSafe(v.reason)))
}
hold[n] = reason
}
start, replay, err := rebuildPlan(b, hold)
return a.reassemble(b, hold, hold, b.src.rec.dropped(), modeDefer, push)
}
// reassemble builds the open batch again off the line's head without the
// units of the originals in out, and reports it with hold deferred for their
// reasons and drops kept.
func (a *app) reassemble(b *batch, out, hold map[int]string, drops []droppedEntry, mode string, push bool) int {
branch := b.pr.Head.Ref
start, replay, err := rebuildPlan(b, out)
if err != nil {
return a.fail(err)
}
@@ -102,7 +107,7 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
case err != nil:
return a.fail(err)
case failed != "":
return a.fail(fmt.Errorf("without what is deferred, %.12s%s no longer applies (%s); defer it as well", failed, originName(b, u), strings.Join(files, ", ")))
return a.fail(fmt.Errorf("without what is deferred, %.12s%s no longer applies (%s); defer it as well", failed, originName(b, u), terminalSafe(strings.Join(files, ", "))))
}
}
head, err := resolve("HEAD")
@@ -113,7 +118,23 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
if err != nil {
return a.fail(err)
}
return a.reclassify(b, picks, hold, drops, mode, push)
}
// reclassify reports the open batch as picks shows it, after an operation
// that adds no original: what the record held stays held, and an original
// the batch neither carries nor records is missing until a run tries it.
func (a *app) reclassify(b *batch, picks *history, hold map[int]string, drops []droppedEntry, mode string, push bool) int {
date, err := branchDate(b.pr.Head.Ref)
if err != nil {
return a.fail(err)
}
recorded := b.recorded()
r := newReport(b)
// None of these changes how the batch is assembled, so the record keeps
// the flag the batch was assembled with.
r.opts = runOptions{NoWorkflowFiles: b.src.savedNoWorkflowFiles(), Mode: mode}
r.dropped = drops
if r.stale, err = keptOut(b, picks); err != nil {
return a.fail(err)
}
@@ -128,9 +149,22 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
r.waiting = append(r.waiting, pick{v: v, status: "waiting", files: e.Files, more: e.More})
case len(e.Files) > 0:
r.deferred = append(r.deferred, pick{v: v, status: "conflict", files: e.Files, more: e.More, failed: e.Commit})
default:
// Merged into main since the batch was assembled, or recorded
// nowhere: a rebuild adds no original, the next run tries it.
r.missing = append(r.missing, v)
}
}
return a.finish(b, r, branch, date, push, true)
return a.finish(b, r, b.pr.Head.Ref, date, push, true)
}
// recorded is what the open batch's record defers; a closed batch's record
// holds nothing back on this one.
func (b *batch) recorded() map[int]deferredEntry {
if !b.src.open {
return map[int]deferredEntry{}
}
return b.src.rec.deferred()
}
// rebuildPlan is where the rebuild of the open batch without the originals in
+331 -2
View File
@@ -261,8 +261,8 @@ func TestDeferLeasesTheHeadItRebuilt(t *testing.T) {
pr := f.batchPR(90, "release-1.1", batchBranch1, gh.prs[0].Body)
a := &app{gh: gh}
b := &batch{line: l, lineHead: lineHead, pr: &pr, prHead: head}
err := a.publish(b, batchBranch1, rebuilt, "title", "body", true)
b := &batch{line: l, lineHead: lineHead, pr: &pr, prHead: head, src: recordSource{carrier: &pr, open: true, code: &pr}}
err := a.publish(b, publication{branch: batchBranch1, head: rebuilt, title: "title", body: "body"}, true)
if err == nil || !strings.Contains(err.Error(), "which the rebuild started from") {
t.Errorf("publish over a head pushed since = %v, want a refusal", err)
}
@@ -378,3 +378,332 @@ func TestSkipAfterThePick(t *testing.T) {
t.Errorf("check = %d\n%s", code, stderr)
}
}
// closeWorld is a batch carrying #5 alone, opened and pushed.
func closeWorld(t *testing.T) (*fixture, *fakeGH, string) {
t.Helper()
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
gh := &fakeGH{}
h1 := openBatch(t, f, gh)
return f, gh, h1
}
// Deferring everything a batch carries closes it: the record says why and
// what it froze while the pull request is still open, then the pull request
// is closed and its branch deleted, leased on that head. The next batch
// tries what was deferred again.
func TestDeferEverythingClosesTheBatch(t *testing.T) {
f, gh, h1 := closeWorld(t)
var log []string
gh.after = map[string]func(){
"createComment": func() { log = append(log, "comment") },
"editPull": func() { log = append(log, "edit") },
"closePull": func() { log = append(log, "close, branch at "+f.remoteHead(batchBranch1)) },
}
code, stdout, stderr := f.exec(gh, "run", "--line", "release-1.1", "--defer", "5", "--reason", "fails the app e2e", "--push")
if code != 0 || !strings.Contains(stderr, "closed https://github.com/cozystack/cozystack/pull/90: without what is deferred it carries nothing beyond release-1.1; the next batch tries them again") {
t.Fatalf("run --defer of the last pick = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
if want := []string{"comment", "edit", "close, branch at " + h1}; !slices.Equal(log, want) || f.remoteHead(batchBranch1) != "" || gh.find(90).State != "closed" {
t.Errorf("writes %q, branch at %q, #90 %s; want %q, the branch deleted and #90 closed", log, f.remoteHead(batchBranch1), gh.find(90).State, want)
}
rec, err := parseRecord(gh.find(90).Body, notifyLine)
if err != nil || rec.Closing == nil || *rec.Closing != (closing{PR: 90, Branch: batchBranch1, Head: h1}) || rec.Built.Options.Mode != modeClose || rec.deferred()[5].Reason != "fails the app e2e" {
t.Errorf("the closed batch's record is %+v, %v; want #5 deferred and the close it froze", rec, err)
}
if !strings.Contains(stdout, `"closing":{"pr":90,"branch":"`+batchBranch1+`","head":"`+h1+`"}`) {
t.Errorf("the body printed does not say what the close froze:\n%s", stdout)
}
code, _, stderr = f.exec(gh, "check", "--line", "release-1.1")
if code != 0 || !strings.Contains(stderr, "warning: deferred on #90 (closed) to the next batch: https://github.com/cozystack/cozystack/pull/5 ") {
t.Errorf("check = %d, want 0 with #5 deferred:\n%s", code, stderr)
}
gh.nextPR = 91
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if got := f.subjects("origin/release-1.1", f.remoteHead(batchBranch1)); !slices.Equal(got, []string{"fix(app): five (#5)"}) || gh.find(91) == nil {
t.Errorf("the next batch carries %q, want #5 tried again on a new pull request", got)
}
}
// A close that stopped part way is finished by run --push, and by nothing
// else, and never past a head it did not freeze; nothing is ever written to
// the closed pull request.
func TestCloseRecovery(t *testing.T) {
deferAll := []string{"run", "--line", "release-1.1", "--defer", "5", "--reason", "fails the app e2e", "--push"}
push := []string{"run", "--line", "release-1.1", "--continue", "--push"}
// moveBranch pushes a commit onto the batch branch, or recreates it, as
// someone else would.
moveBranch := func(f *fixture, base string) string {
f.git("switch", "-q", "--detach", base)
f.git("commit", "-q", "--allow-empty", "-m", "pushed since")
h2 := f.git("rev-parse", "HEAD")
f.git("push", "-q", "origin", "HEAD:refs/heads/"+batchBranch1)
f.git("switch", "-q", "main")
return h2
}
writes := func(gh *fakeGH) int { return len(gh.issueComments) + len(gh.edited) }
t.Run("stopped after the record, then the branch moved", func(t *testing.T) {
f, gh, h1 := closeWorld(t)
gh.after = map[string]func(){"editPull": func() { gh.fail = map[string]error{"pull": errString("HTTP 502")} }}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 || !strings.Contains(stderr, "HTTP 502") {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
h2 := moveBranch(f, h1)
before := writes(gh)
if code, _, stderr := f.exec(gh, push...); code != 2 || !strings.Contains(stderr, h2[:12]) {
t.Errorf("run --push over a moved branch = %d, want 2 naming it:\n%s", code, stderr)
}
if gh.find(90).State != "open" || f.remoteHead(batchBranch1) != h2 || writes(gh) != before {
t.Error("the run acted on a branch that moved since the close froze it")
}
})
// GitHub and the remote are read apart, and either may be the first to
// show that someone pushed while the record was written.
for _, seen := range []string{"by the remote", "by GitHub"} {
t.Run("the branch moved while the record was written, seen "+seen, func(t *testing.T) {
f, gh, h1 := closeWorld(t)
gh.after = map[string]func(){"editPull": func() {
gh.remoteHead = func(string) string { return "" }
if seen == "by GitHub" {
gh.find(90).Head.SHA = strings.Repeat("b", 40)
} else {
moveBranch(f, h1)
}
}}
code, _, stderr := f.exec(gh, deferAll...)
f.git("switch", "-q", "main")
if code != 2 || !strings.Contains(stderr, "which its record froze") || gh.find(90).State != "open" || f.remoteHead(batchBranch1) == "" {
t.Errorf("run = %d, #90 %s, branch %q; want 2, #90 open and the branch kept:\n%s", code, gh.find(90).State, f.remoteHead(batchBranch1), stderr)
}
})
}
t.Run("the close failed", func(t *testing.T) {
f, gh, _ := closeWorld(t)
gh.fail = map[string]error{"closePull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
before := writes(gh)
// Anything but run --push leaves it as it is.
for _, args := range [][]string{
{"run", "--line", "release-1.1", "--continue"},
{"check", "--line", "release-1.1"},
{"fold", "--line", "release-1.1", "--original", "5", "--commit", strings.Repeat("a", 40)},
{"notify", "--line", "release-1.1", "--push"},
} {
if code, _, stderr := f.exec(gh, args...); code != 2 || !strings.Contains(stderr, "being closed") {
t.Errorf("%q = %d, want 2:\n%s", args, code, stderr)
}
f.git("switch", "-q", "main")
}
if writes(gh) != before || gh.find(90).State != "open" {
t.Error("a command other than run --push wrote to the batch")
}
code, _, stderr := f.exec(gh, push...)
if code != 0 || gh.find(90).State != "closed" || f.remoteHead(batchBranch1) != "" || writes(gh) != before {
t.Errorf("run --push = %d, #90 %s, branch %q, %d writes; want the close finished and nothing else\n%s", code, gh.find(90).State, f.remoteHead(batchBranch1), writes(gh)-before, stderr)
}
})
// A run that finishes a close stops there: the change it was asked for
// needs an open batch pull request, and none is left to make it on.
t.Run("a change asked for while the close was left", func(t *testing.T) {
f, gh, _ := closeWorld(t)
gh.fail = map[string]error{"closePull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
code, _, stderr := f.exec(gh, deferAll...)
f.git("switch", "-q", "main")
if code != 2 || !strings.Contains(stderr, "finished the close of #90 and made no other change") || gh.find(90).State != "closed" || f.remoteHead(batchBranch1) != "" {
t.Errorf("run --defer = %d, #90 %s, branch %q; want 2, the close finished and nothing deferred:\n%s", code, gh.find(90).State, f.remoteHead(batchBranch1), stderr)
}
})
t.Run("closed by hand while the close writes", func(t *testing.T) {
f, gh, h1 := closeWorld(t)
gh.after = map[string]func(){"createComment": func() { gh.find(90).State = "closed" }}
code, _, stderr := f.exec(gh, deferAll...)
f.git("switch", "-q", "main")
if code != 2 || !strings.Contains(stderr, "batch pull request #90 is closed now; nothing is written to it") || len(gh.edited) != 0 || f.remoteHead(batchBranch1) != h1 {
t.Errorf("run = %d, edited %q, branch %q; want 2, no edit and the branch kept:\n%s", code, gh.edited, f.remoteHead(batchBranch1), stderr)
}
})
t.Run("the delete failed", func(t *testing.T) {
f, gh, h1 := closeWorld(t)
gh.after = map[string]func(){"closePull": func() { f.refusePushes(true) }}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
f.refusePushes(false)
code, _, stderr := f.exec(gh, "check", "--line", "release-1.1")
if code != 0 || !strings.Contains(stderr, "warning: batch pull request #90 is closed, but its branch "+batchBranch1+" is still at "+h1[:12]) {
t.Errorf("check = %d, want 0 naming the branch:\n%s", code, stderr)
}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue"); code != 2 {
t.Errorf("run without --push = %d, want 2:\n%s", code, stderr)
}
f.git("switch", "-q", "main")
if code, _, stderr := f.exec(gh, push...); code != 0 || f.remoteHead(batchBranch1) != "" {
t.Errorf("run --push = %d, branch %q; want it deleted\n%s", code, f.remoteHead(batchBranch1), stderr)
}
mustRun(t, f, gh, "check", "--line", "release-1.1")
})
// A batch pull request a person opens on the branch a close left owns
// it: neither recovery nor check offers to delete it, moved or not. One
// on another branch does not stop the delete.
for _, tc := range []struct {
name string
branch string
moved bool
}{
{"a batch opened on the branch the close left", batchBranch1, false},
{"a batch opened on the branch the close left, moved since", batchBranch1, true},
{"a batch opened on another branch", "backport-batch/release-1.1-20261027", false},
} {
t.Run(tc.name, func(t *testing.T) {
f, gh, h1 := closeWorld(t)
gh.after = map[string]func(){"closePull": func() { f.refusePushes(true) }}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
f.refusePushes(false)
gh.after = nil
if tc.branch != batchBranch1 {
f.git("push", "-q", "origin", h1+":refs/heads/"+tc.branch)
}
left := h1
if tc.moved {
left = moveBranch(f, h1)
}
gh.prs = append(gh.prs, f.batchPR(91, "release-1.1", tc.branch, "a maintainer's batch"))
taken := tc.branch == batchBranch1
_, _, stderr := f.exec(gh, "check", "--line", "release-1.1")
f.git("switch", "-q", "main")
if named := strings.Contains(stderr, "batch pull request #91 is open on "+batchBranch1); named != taken || taken == strings.Contains(stderr, "--push deletes it") || taken && strings.Contains(stderr, "git push origin --delete") {
t.Errorf("check names the branch as #91's: %v, want %v, and offers no delete of it:\n%s", named, taken, stderr)
}
code, _, stderr := f.exec(gh, push...)
f.git("switch", "-q", "main")
if taken && (code != 2 || !strings.Contains(stderr, "batch pull request #91 is open on "+batchBranch1) || strings.Contains(stderr, "git push origin --delete") || f.remoteHead(batchBranch1) != left || gh.find(91).State != "open") {
t.Errorf("run --push = %d, branch %q; want 2, no delete offered and #91's branch kept:\n%s", code, f.remoteHead(batchBranch1), stderr)
}
if !taken && (code != 0 || f.remoteHead(batchBranch1) != "") {
t.Errorf("run --push = %d, branch %q; want the left branch deleted:\n%s", code, f.remoteHead(batchBranch1), stderr)
}
})
}
t.Run("the branch moved after the close", func(t *testing.T) {
f, gh, h1 := closeWorld(t)
var h2 string
gh.after = map[string]func(){"closePull": func() { h2 = moveBranch(f, h1) }}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 || !strings.Contains(stderr, "is at "+h2[:12]+" now") {
t.Fatalf("run = %d, want 2 naming where the branch moved\n%s", code, stderr)
}
f.git("switch", "-q", "main")
// What was pushed onto the branch may be work the next run cannot
// pick again, so the refusal promises nothing about deleting it.
if code, _, stderr := f.exec(gh, push...); code != 2 || !strings.Contains(stderr, h1[:12]) || !strings.Contains(stderr, h2[:12]) || !strings.Contains(stderr, "#90") || strings.Contains(stderr, "nothing on it is lost") || !strings.Contains(stderr, "check what "+h2[:12]+" carries") {
t.Errorf("run --push = %d, want 2 naming both heads and #90, and what to check before deleting:\n%s", code, stderr)
}
if f.remoteHead(batchBranch1) != h2 {
t.Error("the run moved a branch it did not freeze")
}
if _, err := git(deleteArgs(batchBranch1, h1)...); err == nil || f.remoteHead(batchBranch1) != h2 {
t.Error("the remote took a delete whose lease names a head it no longer has")
}
})
t.Run("a batch of the same day that opened no pull request", func(t *testing.T) {
f, gh, _ := closeWorld(t)
if code, _, stderr := f.exec(gh, deferAll...); code != 0 || f.remoteHead(batchBranch1) != "" {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
// The same day, a new batch reuses the branch name, pushes, and
// fails to open its pull request.
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh.fail = map[string]error{"createPull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, push...); code != 2 {
t.Fatalf("the same-day run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
h2 := f.remoteHead(batchBranch1)
for _, args := range [][]string{push, {"run", "--line", "release-1.1", "--onto", h2, "--push"}} {
if code, _, stderr := f.exec(gh, args...); code != 2 || !strings.Contains(stderr, "git push origin --delete "+batchBranch1) {
t.Errorf("%q = %d, want 2 naming the branch to delete:\n%s", args, code, stderr)
}
f.git("switch", "-q", "main")
}
if f.remoteHead(batchBranch1) != h2 || len(gh.created) != 1 {
t.Errorf("the branch moved to %.12s or a pull request was opened: %q", f.remoteHead(batchBranch1), gh.created)
}
f.git("push", "-q", "origin", "--delete", batchBranch1)
gh.nextPR = 91
mustRun(t, f, gh, push...)
if gh.find(91) == nil {
t.Error("after the branch was deleted the next run opened no batch")
}
})
t.Run("a preview", func(t *testing.T) {
f, gh, _ := closeWorld(t)
gh.fail = map[string]error{"closePull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
before := writes(gh)
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--defer", "5", "--reason", "x")
if code != 2 || !strings.Contains(stderr, "run --line release-1.1 --push finishes") || writes(gh) != before || gh.find(90).State != "open" {
t.Errorf("a preview = %d, want 2 naming the step left and nothing written:\n%s", code, stderr)
}
})
}
// A batch that merged on GitHub and left its branch behind is named when a
// run of the same day refuses to push over that branch.
func TestRefusalNamesTheMergedBatchsBranch(t *testing.T) {
f, gh, _ := closeWorld(t)
head := f.remoteHead(batchBranch1)
mergeBatch(f, gh)
// A commit pushed onto the batch after it merged is left on its branch.
f.git("switch", "-q", "--detach", head)
f.git("commit", "-q", "--allow-empty", "-m", "pushed after the merge")
pushed := f.git("rev-parse", "HEAD")
f.git("push", "-q", "origin", "HEAD:refs/heads/"+batchBranch1)
f.git("switch", "-q", "main")
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push")
if code != 2 || !strings.Contains(stderr, "it is the branch of batch pull request #90, which is merged") || strings.Contains(stderr, "--onto") || !strings.Contains(stderr, "check what "+pushed[:12]+" carries") {
t.Errorf("run --push = %d, want a refusal naming #90:\n%s", code, stderr)
}
}
// A rebuild that stops names the files that no longer apply on the line of
// its refusal, whatever the files are called.
func TestDeferRefusalNamesFilesOnOneLine(t *testing.T) {
f := newFixture(t)
f.lines()
name := "app/x\nwarning: forged.txt"
f.pr(5, "fix(app): five", "", map[string]string{name: "five\n"})
f.pr(6, "fix(app): six", "", map[string]string{name: "five and six\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
f.onLine("release-1.1", "fix(chart): a line-only value", map[string]string{"chart/values.yaml": "replicas: 3\nimage: app:1\n"})
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--defer", "5", "--reason", "x", "--push")
f.git("switch", "-q", "main")
if code != 2 || !strings.Contains(stderr, "no longer applies") {
t.Fatalf("run --defer 5 = %d, want the refusal:\n%s", code, stderr)
}
for _, ln := range strings.Split(stderr, "\n") {
if strings.HasPrefix(ln, "warning: forged") {
t.Errorf("the refusal split onto a line of its own: %q", ln)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"cmp"
"fmt"
"maps"
"slices"
"strings"
"time"
)
// A drop is a maintainer's decision that an original stays off one line,
// for a reason, where the rules would take it. It is kept in the line's
// record and carried from batch to batch, bound to the original's merge
// commit on main, so a record cannot point it at another original. It is
// written only to the open batch's record, like everything else there.
// onLine says whether the line carries v, whatever rule decided it.
func (b *batch) onLine(v *verdict) (bool, error) {
if v.rule == ruleOnLine {
return true, nil
}
if err := b.load(v); err != nil {
return false, err
}
how, err := b.lineHist.carries(v.o, v.commits)
return how != "", err
}
// carried says whether the open batch has a unit of v, by the mapping the
// rebuild takes units off with: found needs every commit of a merged
// original, and one whose pick came out empty is missing from it.
func (b *batch) carried(v *verdict) (bool, error) {
units, err := batchUnits(b.prHead, b.lineHead)
if err != nil {
return false, err
}
for _, u := range units {
if o := b.unitOriginal(u); o != nil && o.o.number == v.o.number {
return true, nil
}
}
return false, nil
}
// redrop drops the originals in numbers from the line, for reason, or with
// undrop withdraws their drops. What the open batch carries of a dropped
// original comes off it, as --defer takes it off; otherwise only the record
// changes, and the batch stays as it is even when the line moved since.
func (a *app) redrop(b *batch, numbers []int, reason string, undrop bool, push bool) int {
if !b.src.open {
return a.fail(fmt.Errorf("no open batch pull request on %s carries a record; a drop is recorded on one", b.line.branch()))
}
drops := map[int]droppedEntry{}
for _, d := range b.src.rec.Dropped {
drops[d.Original] = d
}
mode, today := modeDrop, a.now().UTC().Format(time.DateOnly)
if undrop {
mode = modeUndrop
}
for _, n := range numbers {
v := b.find(n)
_, isDropped := drops[n]
switch {
case undrop && !isDropped:
return a.fail(fmt.Errorf("#%d is not dropped from %s", n, b.line.branch()))
case undrop:
delete(drops, n)
case v == nil:
return a.fail(fmt.Errorf("#%d did not merge into main after %s branched", n, b.line.branch()))
default:
// backport/skip and rules 1 to 4 decide before the line is
// looked at, so whether it carries the original is asked here.
on, err := b.onLine(v)
if err != nil {
return a.fail(err)
}
if on {
return a.fail(fmt.Errorf("#%d is already on %s; a drop cannot remove code the line has", n, b.line.branch()))
}
drops[n] = droppedEntry{Original: n, Merge: v.o.merge, Reason: reason, On: b.pr.Number, Date: today}
}
}
active := map[int]droppedEntry{}
for n, d := range drops {
if b.find(n) != nil {
active[n] = d
}
}
b.drops = active
out := map[int]string{}
for _, n := range numbers {
v := b.find(n)
if v == nil {
continue
}
if err := b.judge(v, active); err != nil {
return a.fail(err)
}
if err := b.load(v); err != nil {
return a.fail(err)
}
if undrop {
continue
}
on, err := b.carried(v)
if err != nil {
return a.fail(err)
}
if on {
out[n] = "dropped"
}
}
hold := map[int]string{}
for n, e := range b.recorded() {
if e.Reason != "" {
hold[n] = e.Reason
}
}
list := slices.SortedFunc(maps.Values(drops), func(x, y droppedEntry) int { return cmp.Compare(x.Original, y.Original) })
if len(out) > 0 {
maps.Copy(out, hold)
return a.reassemble(b, out, hold, list, mode, push)
}
if err := a.warnReset(b.pr.Head.Ref, b.prHead); err != nil {
return a.fail(err)
}
if _, err := git("switch", "--quiet", "--no-track", "-C", b.pr.Head.Ref, "--end-of-options", b.prHead); err != nil {
return a.fail(err)
}
return a.reclassify(b, b.picks, hold, list, mode, push)
}
// bindDrops holds each drop the record keeps to the original it names: a
// drop of an original merged since the line branched has to name that
// original's merge commit, and one of an earlier original a merge of it on
// main's first-parent line. The first apply; the others are kept and carried
// on, and decide nothing.
func (b *batch) bindDrops(merges []*original) (map[int]droppedEntry, error) {
return bindEntries(b.src.rec.dropped(), merges, b.mainHead, func(d droppedEntry) string {
return fmt.Sprintf("batch pull request #%d drops #%d by %.12s", b.src.carrier.Number, d.Original, d.Merge)
})
}
// bindEntries binds drops to main: one whose original is among merges, the
// originals main merged since the line branched, has to name that
// original's merge exactly; any other has to be a merge of its number on
// main's first-parent line up to mainHead, as an inherited drop is. It
// returns the drops whose originals are among merges; about says what an
// error is about.
func bindEntries(drops []droppedEntry, merges []*original, mainHead string, about func(droppedEntry) string) (map[int]droppedEntry, error) {
byMerge, byNumber := map[string]*original{}, map[int]*original{}
for _, o := range merges {
byMerge[o.merge], byNumber[o.number] = o, o
}
active := map[int]droppedEntry{}
for _, d := range drops {
at := about(d)
if o := byMerge[d.Merge]; o != nil {
if o.number != d.Original {
return nil, fmt.Errorf("%s, which merged #%d", at, o.number)
}
active[d.Original] = d
continue
}
if o := byNumber[d.Original]; o != nil {
return nil, fmt.Errorf("%s, but #%d merged as %.12s", at, d.Original, o.merge)
}
ok, err := mergedOnMain(d.Merge, mainHead, d.Original)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%s, which is no merge of it on main", at)
}
}
return active, nil
}
// bindDrops binds each drop a report keeps to main exactly as the record's
// drops are bound, over the window the report classified: main and the
// line at the heads it was made from. Its errors read after "report <id>".
func (d *reportDoc) bindDrops() error {
if len(d.Overlays.Dropped) == 0 {
return nil
}
for _, h := range []struct{ name, head string }{{"main", d.Built.Main}, {d.Line, d.Built.Line}} {
have, err := lookup(h.head)
if err != nil {
return err
}
if have == "" {
return fmt.Errorf("was made on %s at %.12s, which this clone does not have; fetch %s and run again", h.name, h.head, h.name)
}
}
out, err := git("merge-base", "--end-of-options", d.Built.Main, d.Built.Line)
if err != nil {
return fmt.Errorf("was made from a line that shares no history with main: %w", err)
}
merges, _, err := mainMerges(strings.TrimSpace(out), d.Built.Main)
if err != nil {
return err
}
active, err := bindEntries(d.Overlays.Dropped, merges, d.Built.Main, func(e droppedEntry) string {
return fmt.Sprintf("drops #%d by %.12s", e.Original, e.Merge)
})
if err != nil {
return err
}
// A drop decides only for an original the report classified, and only
// once every earlier rule has passed it by; an inherited one decides
// nothing.
where, err := d.categories()
if err != nil {
return err
}
for _, n := range d.Primary.Skipped.Dropped {
if _, ok := active[n]; !ok {
return fmt.Errorf("has #%d dropped, but no drop of it binds to an original it classified", n)
}
}
for n := range active {
switch c := where[n]; c {
case "skipped.dropped", "skipped.skip-label", "skipped.before-start", "skipped.type", "skipped.breaking", "skipped.touches", "skipped.on-line":
default:
return fmt.Errorf("drops #%d, which it has %s, though a drop decides before that", n, or(c, "nowhere"))
}
}
return nil
}
// mergedOnMain says whether merge is on main's first-parent line up to
// mainHead and its message names pull request n, as mainMerges reads one.
func mergedOnMain(merge, mainHead string, n int) (bool, error) {
have, err := lookup(merge)
if err != nil || have == "" {
return false, err
}
if ok, err := isAncestor(merge, mainHead); err != nil || !ok {
return false, err
}
line, err := revList("--first-parent", "--end-of-options", mainHead)
if err != nil || !slices.Contains(line, merge) {
return false, err
}
msgs, err := commitMessages([]string{merge})
if err != nil {
return false, err
}
number, _, _ := mergedPR(msgs[merge].message)
return number == n, nil
}
+576
View File
@@ -0,0 +1,576 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"slices"
"strings"
"testing"
)
// recordOf is the record the open batch #90 carries now.
func recordOf(t *testing.T, gh *fakeGH) *lineRecord {
t.Helper()
rec, err := parseRecord(gh.find(90).Body, notifyLine)
if err != nil || rec == nil {
t.Fatalf("#90 carries no readable record: %v", err)
}
return rec
}
func dropped(rec *lineRecord) []int {
var out []int
for _, d := range rec.Dropped {
out = append(out, d.Original)
}
return out
}
// A drop takes a picked original off the batch and keeps it off the line:
// a run that continues the batch does not pick it, and nor does the next
// batch, which carries the drop over from the merged one.
func TestDropTakesAnOriginalOffTheLine(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
mc6 := f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
stdout := mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "6", "--reason", "needs the rework of #4", "--push")
if got := f.subjects("origin/release-1.1", f.remoteHead(batchBranch1)); !slices.Equal(got, []string{"fix(app): five (#5)"}) {
t.Errorf("after the drop the batch carries %q, want #5 alone", got)
}
rec := recordOf(t, gh)
if want := (droppedEntry{Original: 6, Merge: mc6, Reason: "needs the rework of #4", On: 90, Date: "2026-10-26"}); len(rec.Dropped) != 1 || rec.Dropped[0] != want || rec.Built.Options.Mode != modeDrop {
t.Errorf("the record drops %+v (%s), want %+v", rec.Dropped, rec.Built.Options.Mode, want)
}
if s := bodyReportOf(t, stdout); !slices.Equal(s.doc.Primary.Skipped.Dropped, []int{6}) || !slices.Equal(droppedNumbers(s.doc.Overlays.Dropped), []int{6}) {
t.Errorf("the report has %v dropped and %v as the decision", s.doc.Primary.Skipped.Dropped, s.doc.Overlays.Dropped)
}
if !strings.Contains(stdout, "<details><summary>Dropped from `release-1.1` by a maintainer</summary>") {
t.Errorf("the body does not list #6 as dropped:\n%s", stdout)
}
code, _, stderr := f.exec(gh, "check", "--line", "release-1.1", "--cut")
if code != 1 || !strings.Contains(stderr, "note: #6 is dropped from release-1.1 on #90 (needs the rework of #4): https://github.com/cozystack/cozystack/pull/6 ") {
t.Errorf("check --cut = %d, want #5 to fail it and a note on #6:\n%s", code, stderr)
}
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if got := f.subjects("origin/release-1.1", f.remoteHead(batchBranch1)); !slices.Equal(got, []string{"fix(app): five (#5)", "fix(app): seven (#7)"}) || !slices.Equal(dropped(recordOf(t, gh)), []int{6}) {
t.Errorf("the continued batch carries %q and drops %v", got, dropped(recordOf(t, gh)))
}
mergeBatch(f, gh)
f.now = f.now.AddDate(0, 0, 3)
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
gh.nextPR = 91
stdout = mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
rec, err := parseRecord(stdout, notifyLine)
if got := f.subjects("origin/release-1.1", f.remoteHead("backport-batch/release-1.1-20261029")); err != nil || !slices.Equal(got, []string{"fix(app): eight (#8)"}) || !slices.Equal(dropped(rec), []int{6}) {
t.Errorf("the next batch carries %q and drops %v (%v); want #8 alone, #6 still dropped", got, rec, err)
}
}
// Dropping an original the batch does not carry changes only the record:
// the batch is not rebuilt, so a line that moved since does not move it.
func TestDropIsRecordOnlyWhenTheLineMoved(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only seven", map[string]string{"app/seven.txt": "line\n"})
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
gh := &fakeGH{}
head := openBatch(t, f, gh)
if recordOf(t, gh).deferred()[7].Commit == "" {
t.Fatal("#7 is not a recorded conflict")
}
f.onLine("release-1.1", "fix(chart): a line-only value", map[string]string{"chart/values.yaml": "replicas: 3\nimage: app:1\n"})
mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "7", "--reason", "needs #4", "--push")
rec := recordOf(t, gh)
if f.remoteHead(batchBranch1) != head || !slices.Equal(dropped(rec), []int{7}) || len(rec.deferred()) != 0 || len(gh.edited) != 1 {
t.Errorf("the batch moved to %.12s (from %.12s), the record drops %v and defers %v; want only the record changed", f.remoteHead(batchBranch1), head, dropped(rec), rec.deferred())
}
}
// A drop is written to the open batch's record, and to no other: with no
// batch open, or none ever, it is refused and nothing is written.
func TestDropNeedsAnOpenBatch(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only seven", map[string]string{"app/seven.txt": "line\n"})
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
gh := &fakeGH{}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--drop", "5", "--reason", "r", "--push"); code != 2 || !strings.Contains(stderr, "no open batch pull request on release-1.1 carries a record; a drop is recorded on one") {
t.Errorf("a drop on a line that never had a batch = %d:\n%s", code, stderr)
}
openBatch(t, f, gh)
mergeBatch(f, gh)
written := len(gh.edited) + len(gh.issueComments)
for _, args := range [][]string{{"--drop", "7", "--reason", "r", "--push"}, {"--undrop", "7", "--push"}} {
if code, _, stderr := f.exec(gh, append([]string{"run", "--line", "release-1.1"}, args...)...); code != 2 || !strings.Contains(stderr, "carries a record; a drop is recorded on one") {
t.Errorf("%q after the batch merged = %d:\n%s", args, code, stderr)
}
}
if len(gh.edited)+len(gh.issueComments) != written {
t.Error("a refused drop wrote to GitHub")
}
}
// A drop is a decision kept beside whatever else decides an original: the
// rule, label or carry that applies first is its category, and the drop
// stays on record. backport/include does not lift it.
func TestDropOverlay(t *testing.T) {
setup := func(t *testing.T) (*fixture, *fakeGH) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
f.pr(7, "feat(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
gh := &fakeGH{labels: map[string][]int{labelInclude: {7}}}
openBatch(t, f, gh)
return f, gh
}
category := func(t *testing.T, gh *fakeGH, n int) string {
t.Helper()
s := bodyReportOf(t, gh.find(90).Body)
where, err := s.doc.categories()
if err != nil {
t.Fatal(err)
}
return where[n]
}
merge := func(f *fixture, n int) string {
mc, _, _ := strings.Cut(f.git("log", "--first-parent", "--format=%H", "--grep", fmt.Sprintf("(#%d)$", n), "main"), "\n")
return mc
}
drop := func(t *testing.T, f *fixture, gh *fakeGH, n string) {
t.Helper()
mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", n, "--reason", "not for 1.1", "--push")
}
t.Run("then backport/skip", func(t *testing.T) {
f, gh := setup(t)
drop(t, f, gh, "6")
gh.labels[labelSkip] = []int{6}
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if got := category(t, gh, 6); got != "skipped.skip-label" || !slices.Equal(dropped(recordOf(t, gh)), []int{6}) {
t.Errorf("#6 is %q, dropped %v", got, dropped(recordOf(t, gh)))
}
})
t.Run("then picked onto the line by hand", func(t *testing.T) {
f, gh := setup(t)
drop(t, f, gh, "6")
pickOnLine(f, 6, map[string]string{"app/six.txt": "6\n"})
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if got := category(t, gh, 6); got != "skipped.on-line" || !slices.Equal(dropped(recordOf(t, gh)), []int{6}) {
t.Errorf("#6 is %q, dropped %v", got, dropped(recordOf(t, gh)))
}
})
t.Run("a feat backport/include let in", func(t *testing.T) {
f, gh := setup(t)
drop(t, f, gh, "7")
if got := category(t, gh, 7); got != "skipped.dropped" || slices.Contains(f.subjects("origin/release-1.1", f.remoteHead(batchBranch1)), "feat(app): seven (#7)") {
t.Errorf("#7 is %q; want it dropped and off the batch", got)
}
if !strings.Contains(gh.find(90).Body, "`backport/include`, skipped by a maintainer's drop") {
t.Errorf("the overrides do not say what became of #7:\n%s", gh.find(90).Body)
}
})
t.Run("undropped", func(t *testing.T) {
f, gh := setup(t)
drop(t, f, gh, "6")
head := f.remoteHead(batchBranch1)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--undrop", "6", "--push")
if got := category(t, gh, 6); got != "missing" || len(recordOf(t, gh).Dropped) != 0 || f.remoteHead(batchBranch1) != head || recordOf(t, gh).Built.Options.Mode != modeUndrop {
t.Errorf("#6 is %q, dropped %v; want it missing until the next run, the batch where it was", got, dropped(recordOf(t, gh)))
}
if code, stdout, _ := f.exec(gh, "check", "--line", "release-1.1"); code != 1 || !strings.Contains(stdout, "/pull/6 ") {
t.Errorf("check = %d, want #6 missing:\n%s", code, stdout)
}
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if got := category(t, gh, 6); got != "picked" {
t.Errorf("after the next run #6 is %q, want it picked again", got)
}
})
t.Run("a new original during a change to the record alone", func(t *testing.T) {
f, gh := setup(t)
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
drop(t, f, gh, "7")
mustRun(t, f, gh, "run", "--line", "release-1.1", "--undrop", "7", "--push")
if got := category(t, gh, 8); got != "missing" {
t.Errorf("#8 is %q, want missing until a run tries it", got)
}
})
// A stored drop is bound to the merge it names: one that names another
// original's, or one of no merge of it, is refused; one of an original
// merged before the line branched is kept and decides nothing.
t.Run("a stored drop of an earlier commit that names the original", func(t *testing.T) {
// Before release-1.1 branched, a commit on main already ended in
// (#6); the drop has to name the merge of #6 the line can take.
f := newFixture(t)
f.release("release-1.0")
f.tag("v1.0.0")
early := f.squash(6, "docs: what six will change", "", map[string]string{"docs/six.md": "6\n"})
f.release("release-1.1")
f.tag("v1.1.0")
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
drop(t, f, gh, "6")
gh.find(90).Body = strings.Replace(gh.find(90).Body, `{"original":6,"merge":"`+merge(f, 6)+`"`, `{"original":6,"merge":"`+early+`"`, 1)
if code, _, stderr := f.exec(gh, "check", "--line", "release-1.1"); code != 2 || !strings.Contains(stderr, "but #6 merged as") {
t.Errorf("check = %d, want 2 naming the merge of #6:\n%s", code, stderr)
}
})
t.Run("a stored drop of a commit inside a pull request", func(t *testing.T) {
// A commit of a pull request's own branch can end in its number too,
// but only the merge on main's first-parent line is the original.
f := newFixture(t)
f.release("release-1.0")
f.tag("v1.0.0")
mc3 := f.prCommits(3, "feat(app): three", "", change{files: map[string]string{"app/three.txt": "3\n"}, message: "feat(app): the part of three (#3)"})
inner := f.git("rev-parse", mc3+"^2")
f.release("release-1.1")
f.tag("v1.1.0")
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
drop(t, f, gh, "6")
gh.find(90).Body = strings.Replace(gh.find(90).Body, `{"original":6,"merge":"`+merge(f, 6)+`"`, `{"original":3,"merge":"`+inner+`"`, 1)
if code, _, stderr := f.exec(gh, "check", "--line", "release-1.1"); code != 2 || !strings.Contains(stderr, "which is no merge of it on main") {
t.Errorf("check = %d, want 2:\n%s", code, stderr)
}
})
for _, tc := range []struct {
name string
original func(f *fixture) (int, string)
refused bool
}{
{"another original's merge", func(f *fixture) (int, string) { return 6, merge(f, 5) }, true},
{"a commit that merged no pull request", func(f *fixture) (int, string) { return 6, f.git("rev-list", "--max-parents=0", "main") }, true},
{"a merge from before the line branched, of another pull request", func(f *fixture) (int, string) { return 2, merge(f, 1) }, true},
{"a merge from before the line branched", func(f *fixture) (int, string) { return 1, merge(f, 1) }, false},
} {
t.Run("a stored drop of "+tc.name, func(t *testing.T) {
f, gh := setup(t)
drop(t, f, gh, "6")
n, mc := tc.original(f)
gh.find(90).Body = strings.Replace(gh.find(90).Body, `{"original":6,"merge":"`+merge(f, 6)+`"`, fmt.Sprintf(`{"original":%d,"merge":"%s"`, n, mc), 1)
code, _, stderr := f.exec(gh, "check", "--line", "release-1.1")
switch {
case tc.refused && (code != 2 || !strings.Contains(stderr, fmt.Sprintf("drops #%d by %.12s", n, mc))):
t.Errorf("check = %d, want 2 naming the drop:\n%s", code, stderr)
case !tc.refused && code == 2:
t.Errorf("check = %d, want the drop kept:\n%s", code, stderr)
case !tc.refused:
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
if !slices.Equal(dropped(recordOf(t, gh)), []int{1}) || category(t, gh, 6) != "picked" {
t.Errorf("the next run drops %v and puts #6 in %q; want #1's drop carried on and #6 picked", dropped(recordOf(t, gh)), category(t, gh, 6))
}
}
})
}
}
// What a drop cannot do is refused before anything is touched.
func TestDropRefuses(t *testing.T) {
f := newFixture(t)
f.lines()
mc5 := f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{labels: map[string][]int{labelSkip: {7, 9}}}
head := openBatch(t, f, gh)
f.onLine("release-1.1", "fix(app): five\n\n(cherry picked from commit "+mc5+")", map[string]string{"app/five.txt": "5\n"})
// The line carries these too, by hand, while backport/skip and rule 2
// decide them before the line is looked at.
mc7 := f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
mc8 := f.pr(8, "feat(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
f.onLine("release-1.1", "fix(app): seven\n\n(cherry picked from commit "+mc7+")", map[string]string{"app/seven.txt": "7\n"})
f.onLine("release-1.1", "feat(app): eight\n\n(cherry picked from commit "+mc8+")", map[string]string{"app/eight.txt": "8\n"})
// #9 is on the line only through a pick of its own commit, which a later
// line change has since moved away from what main has.
mc9 := f.pr(9, "fix(app): nine", "", map[string]string{"app/nine.txt": "9\n"})
f.onLine("release-1.1", "fix(app): nine\n\n(cherry picked from commit "+f.git("rev-parse", mc9+"^2")+")", map[string]string{"app/nine.txt": "9\n"})
f.onLine("release-1.1", "fix(app): nine, as the line needs it", map[string]string{"app/nine.txt": "nine on release-1.1\n"})
for _, tc := range []struct {
args []string
why string
}{
{[]string{"--drop", "4242", "--reason", "r"}, "#4242 did not merge into main"},
{[]string{"--drop", "5", "--reason", "r"}, "already on release-1.1; a drop cannot remove code the line has"},
{[]string{"--drop", "7", "--reason", "r"}, "#7 is already on release-1.1; a drop cannot remove code the line has"},
{[]string{"--drop", "8", "--reason", "r"}, "#8 is already on release-1.1; a drop cannot remove code the line has"},
{[]string{"--drop", "9", "--reason", "r"}, "#9 is already on release-1.1; a drop cannot remove code the line has"},
{[]string{"--drop", "6"}, "--drop needs a --reason"},
{[]string{"--drop", "6", "--reason", "r", "--defer", "6"}, "give one"},
{[]string{"--drop", "6", "--reason", "r", "--continue"}, "takes no --onto, --continue or --date"},
{[]string{"--drop", "6", "--reason", "r", "--onto", head}, "takes no --onto, --continue or --date"},
{[]string{"--drop", "6", "--reason", "r", "--date", "2026-10-26"}, "takes no --onto, --continue or --date"},
{[]string{"--undrop", "6", "--reason", "r"}, "--undrop takes no --reason"},
{[]string{"--undrop", "6"}, "#6 is not dropped from release-1.1"},
{[]string{"--drop", "6", "--undrop", "6", "--reason", "r"}, "give one"},
{[]string{"--drop", "six", "--reason", "r"}, "not a list of pull request numbers"},
} {
code, _, stderr := f.exec(gh, append(append([]string{"run", "--line", "release-1.1"}, tc.args...), "--push")...)
f.git("switch", "-q", "main")
if code != 2 || !strings.Contains(stderr, tc.why) {
t.Errorf("run %q = %d, want 2 with %q:\n%s", tc.args, code, tc.why, stderr)
}
}
if f.remoteHead(batchBranch1) != head || len(gh.edited) != 0 || len(reportsPosted(gh)) != 1 {
t.Errorf("a refused drop moved the batch to %.12s or wrote %q", f.remoteHead(batchBranch1), gh.edited)
}
}
// A drop or an undrop asked for while a close was left changes nothing: the
// run finishes the close, and no open batch pull request is left to record
// it on.
func TestDropDuringAnUnfinishedClose(t *testing.T) {
deferAll := []string{"run", "--line", "release-1.1", "--defer", "5", "--reason", "fails the app e2e", "--push"}
for _, tc := range []struct {
name string
stop func(f *fixture, gh *fakeGH)
refused bool
args []string
}{
{"a drop after the close failed", func(f *fixture, gh *fakeGH) { gh.fail = map[string]error{"closePull": errString("HTTP 502")} }, false, []string{"--drop", "6", "--reason", "not for 1.1"}},
{"an undrop after the delete failed", func(f *fixture, gh *fakeGH) {
gh.after = map[string]func(){"closePull": func() { f.refusePushes(true) }}
}, true, []string{"--undrop", "6"}},
} {
t.Run(tc.name, func(t *testing.T) {
f, gh, _ := closeWorld(t)
tc.stop(f, gh)
if code, _, stderr := f.exec(gh, deferAll...); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
if tc.refused {
f.refusePushes(false)
}
gh.after = nil
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
code, _, stderr := f.exec(gh, append([]string{"run", "--line", "release-1.1", "--push"}, tc.args...)...)
f.git("switch", "-q", "main")
rec, err := parseRecord(gh.find(90).Body, notifyLine)
if code != 2 || !strings.Contains(stderr, "finished the close of #90 and made no other change") || gh.find(90).State != "closed" || f.remoteHead(batchBranch1) != "" || err != nil || len(rec.Dropped) != 0 {
t.Errorf("run %q = %d, #90 %s, branch %q, record %+v, %v; want 2, the close finished and nothing dropped:\n%s", tc.args, code, gh.find(90).State, f.remoteHead(batchBranch1), rec, err, stderr)
}
})
}
}
// The batch carries an original whose first commit the line already had, so
// its pick came out empty and its unit holds the rest: a drop still takes
// that unit off.
func TestDropTakesOffAUnitWithAnEmptyPick(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): the first part, on its own", map[string]string{"app/already.txt": "first\n"})
f.prCommits(5, "fix(app): a fix in two parts", "",
change{files: map[string]string{"app/already.txt": "first\n"}},
change{files: map[string]string{"app/new.txt": "second\n"}},
)
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "5", "--reason", "not for 1.1", "--push")
head := f.remoteHead(batchBranch1)
if got := f.subjects("origin/release-1.1", head); !slices.Equal(got, []string{"fix(app): six (#6)"}) || f.git("ls-tree", "--name-only", head, "--", "app/new.txt") != "" {
t.Errorf("after the drop the batch carries %q, want #6 alone and nothing of #5", got)
}
}
// A report keeps each drop the record keeps, with the merge commit it is
// bound to, its reason and the batch pull request that recorded it, so an
// earlier report still says why after the drop is withdrawn.
func TestReportKeepsEachDrop(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
mc6 := f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
stdout := mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "6", "--reason", "needs the rework of #4", "--push")
dropped := bodyReportOf(t, stdout)
want := `"overlays":{"dropped":[{"original":6,"merge":"` + mc6 + `","reason":"needs the rework of #4","on":90,"date":"2026-10-26"}]`
if !strings.Contains(dropped.raw, want) {
t.Errorf("the report does not keep the drop with its merge and reason:\n%s", dropped.raw)
}
mustRun(t, f, gh, "run", "--line", "release-1.1", "--undrop", "6", "--push")
if raw := bodyReportOf(t, gh.find(90).Body).raw; !strings.Contains(raw, `"overlays":{"dropped":[],`) {
t.Errorf("the report after the undrop still keeps a drop:\n%s", raw)
}
code, out, stderr := f.exec(gh, "report", "--pr", "90", "--id", dropped.doc.ID)
if code != 0 || !strings.Contains(out, "dropped: #6, merged as "+mc6[:12]+", on #90: needs the rework of #4\n") {
t.Errorf("report --id of the earlier report = %d, want it to show the drop:\n%s%s", code, out, stderr)
}
}
// A report's drop is held to the record's rules and bound to main as the
// record's drops are: a reason a run does not write, another original's
// merge, or an earlier commit on main that names the same number is
// refused, however the ID was made to match, in the body and in a trusted
// report comment alike.
func TestReportRefusesAForgedDrop(t *testing.T) {
f := newFixture(t)
f.release("release-1.0")
f.tag("v1.0.0")
early := f.squash(6, "docs: what six will change", "", map[string]string{"docs/six.md": "6\n"})
f.release("release-1.1")
f.tag("v1.1.0")
mc5 := f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
mc6 := f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "6", "--reason", "not for 1.1", "--push")
body, comments := gh.find(90).Body, slices.Clone(gh.issueComments)
s := bodyReportOf(t, body)
for _, tc := range []struct{ name, old, new, why string }{
{"a reason of two lines", `"reason":"not for 1.1"`, `"reason":"not for 1.1\nwarning: forged"`, "which is no reason a run writes"},
{"another original's merge", `"merge":"` + mc6 + `"`, `"merge":"` + mc5 + `"`, "which merged #5"},
{"an earlier commit that names the same number", `"merge":"` + mc6 + `"`, `"merge":"` + early + `"`, "but #6 merged as " + mc6[:12]},
} {
raw := strings.Replace(s.raw, tc.old, tc.new, 1)
if raw == s.raw {
t.Fatalf("the report holds no %s to change:\n%s", tc.old, s.raw)
}
var d reportDoc
if err := decodeStrict(raw, &d); err != nil {
t.Fatalf("the changed report does not decode: %v", err)
}
d.ID = reportID(d)
forged := storedReport{doc: d, raw: reportJSON(d)}
refused := func(t *testing.T, args ...string) {
t.Helper()
code, out, stderr := f.exec(gh, append([]string{"report", "--pr", "90"}, args...)...)
if code != 2 || !strings.Contains(stderr, tc.why) || strings.Contains(out+stderr, "\nwarning: forged") {
t.Errorf("report of a forged drop = %d, want 2 with %q:\n%s%s", code, tc.why, out, stderr)
}
}
t.Run(tc.name+", in the body", func(t *testing.T) {
gh.find(90).Body = strings.Replace(body, reportBlock(s.raw), reportBlock(forged.raw), 1)
defer func() { gh.find(90).Body = body }()
refused(t)
})
t.Run(tc.name+", in a comment", func(t *testing.T) {
text, err := reportComment(forged)
if err != nil {
t.Fatal(err)
}
c := issueComment{ID: 9000, Body: text}
c.User.Login, c.User.Type = "maintainer", "User"
gh.issueComments = append(slices.Clone(comments), c)
defer func() { gh.issueComments = comments }()
refused(t, "--id", d.ID)
})
}
}
// droppedNumbers are the originals a report's drops name.
func droppedNumbers(es []droppedEntry) []int {
var out []int
for _, e := range es {
out = append(out, e.Original)
}
return out
}
// dropWorld has #3 merged before release-1.1 branched, an earlier commit on
// main that names #6, and a batch with #5 and #6 from which #6 is dropped.
func dropWorld(t *testing.T) (f *fixture, gh *fakeGH, inherited, mc5, mc6 string) {
t.Helper()
f = newFixture(t)
f.release("release-1.0")
f.tag("v1.0.0")
inherited = f.prCommits(3, "feat(app): three", "", change{files: map[string]string{"app/three.txt": "3\n"}, message: "feat(app): part of three (#3)"})
f.squash(6, "docs: what six will change", "", map[string]string{"docs/six.md": "6\n"})
f.release("release-1.1")
f.tag("v1.1.0")
mc5 = f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
mc6 = f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh = &fakeGH{}
openBatch(t, f, gh)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--drop", "6", "--reason", "not for 1.1", "--push")
return f, gh, inherited, mc5, mc6
}
// A report's drops and its categories hold each other up: an original it
// has dropped needs a drop that binds to it, and a drop of an original it
// classified needs that original dropped or decided by an earlier rule. A
// drop inherited from before the line branched decides nothing and stays
// readable. Each holds in the body and in a trusted report comment.
func TestReportTiesDropsToTheirCategory(t *testing.T) {
f, gh, inherited, mc5, _ := dropWorld(t)
body, comments := gh.find(90).Body, slices.Clone(gh.issueComments)
s := bodyReportOf(t, body)
kept := func(n int, merge string) droppedEntry {
return droppedEntry{Original: n, Merge: merge, Reason: "carried from an earlier batch", On: 89, Date: "2026-10-25"}
}
for _, tc := range []struct {
name string
change func(d *reportDoc)
why string
}{
{"an inherited drop in place of the classified one", func(d *reportDoc) { d.Overlays.Dropped[0] = kept(3, inherited) }, "has #6 dropped, but keeps no drop of it"},
{"an inherited drop classified as dropped", func(d *reportDoc) {
d.Primary.Skipped.Dropped = append(d.Primary.Skipped.Dropped, 3)
d.Overlays.Dropped = append(d.Overlays.Dropped, kept(3, inherited))
}, "has #3 dropped, but no drop of it binds to an original it classified"},
{"a drop of an original it picked", func(d *reportDoc) { d.Overlays.Dropped = append(d.Overlays.Dropped, kept(5, mc5)) }, "drops #5, which it has picked"},
{"an inherited drop beside the classified one", func(d *reportDoc) { d.Overlays.Dropped = append(d.Overlays.Dropped, kept(3, inherited)) }, ""},
} {
var d reportDoc
if err := decodeStrict(s.raw, &d); err != nil {
t.Fatal(err)
}
tc.change(&d)
d.canonical()
d.ID = reportID(d)
forged := storedReport{doc: d, raw: reportJSON(d)}
check := func(t *testing.T, args ...string) {
t.Helper()
code, out, stderr := f.exec(gh, append([]string{"report", "--pr", "90"}, args...)...)
switch {
case tc.why == "" && (code != 0 || !strings.Contains(out, "#6 skipped.dropped") || !strings.Contains(out, "dropped: #3, merged as "+inherited[:12]) || strings.Contains(out, "#3 skipped.dropped")):
t.Errorf("report of a sound report = %d, want it read with #3's drop beside #6's:\n%s%s", code, out, stderr)
case tc.why != "" && (code != 2 || out != "" || !strings.Contains(stderr, tc.why)):
t.Errorf("report = %d, want 2 with %q:\n%s%s", code, tc.why, out, stderr)
}
}
t.Run(tc.name+", in the body", func(t *testing.T) {
gh.find(90).Body = strings.Replace(body, reportBlock(s.raw), reportBlock(forged.raw), 1)
defer func() { gh.find(90).Body = body }()
check(t)
})
t.Run(tc.name+", in a comment", func(t *testing.T) {
text, err := reportComment(forged)
if err != nil {
t.Fatal(err)
}
c := issueComment{ID: 9001, Body: text}
c.User.Login, c.User.Type = "maintainer", "User"
gh.issueComments = append(slices.Clone(comments), c)
defer func() { gh.issueComments = comments }()
check(t, "--id", d.ID)
})
}
}
+208 -7
View File
@@ -18,6 +18,7 @@ package main
import (
"bytes"
"cmp"
"fmt"
"os"
"os/exec"
@@ -36,6 +37,8 @@ type fixture struct {
dir string
remote string
now time.Time
// tool is the commit the tool says it was built from.
tool string
}
func newFixture(t *testing.T) *fixture {
@@ -251,15 +254,114 @@ type fakeGH struct {
issueComments []issueComment
// bot is what BACKPORT_BATCH_BOT would say.
bot string
// listed counts the pages of recentPulls read.
listed int
// closedWrites names every write the tool attempted on a pull request
// that is no longer open; exec fails the test on one.
closedWrites []string
// fail makes the next call of an operation, by its method name, fail.
fail map[string]error
// nextPR is the number createPull gives, 90 when unset.
nextPR int
// remoteHead reads a branch's head off the fixture's remote: GitHub
// reports an open pull request at what its branch has.
remoteHead func(string) string
// commented lists the pull request each comment went to, in order.
commented []int
// after runs once an operation, by its method name, has succeeded: what
// someone else does on GitHub meanwhile.
after map[string]func()
}
// live is pr as GitHub reports it now: an open one at the head its branch
// has on the remote.
func (g *fakeGH) live(pr pullRequest) pullRequest {
if g.remoteHead != nil && (pr.State == "" || pr.State == "open") {
if head := g.remoteHead(pr.Head.Ref); head != "" {
pr.Head.SHA = head
}
}
return pr
}
// inject is the failure a test set for the next call of op, once.
func (g *fakeGH) inject(op string) error {
err := g.fail[op]
delete(g.fail, op)
return err
}
func (g *fakeGH) pull(n int) (pullRequest, error) {
if err := g.inject("pull"); err != nil {
return pullRequest{}, err
}
for _, pr := range g.prs {
if pr.Number == n {
return g.live(pr), nil
}
}
return pullRequest{}, fmt.Errorf("HTTP 404: no pull request #%d", n)
}
// isOpen says whether the fake has pull request n open, or does not know it.
func (g *fakeGH) isOpen(n int) bool {
for _, pr := range g.prs {
if pr.Number == n {
return pr.State == "" || pr.State == "open"
}
}
return true
}
// write refuses a write to a pull request that is no longer open, which the
// tool must never attempt.
func (g *fakeGH) write(n int, what string) error {
if g.isOpen(n) {
return nil
}
g.closedWrites = append(g.closedWrites, fmt.Sprintf("%s on #%d", what, n))
return fmt.Errorf("#%d is closed", n)
}
// recentPulls pages every pull request against base, newest first, as the
// REST listing sorted by creation does; a higher number is a newer one.
func (g *fakeGH) recentPulls(base string, page int) ([]pullRequest, error) {
g.listed++
var all []pullRequest
for _, pr := range g.prs {
if pr.Base.Ref == base {
all = append(all, pr)
}
}
slices.SortFunc(all, func(a, b pullRequest) int { return b.Number - a.Number })
from := min(len(all), (page-1)*recentPullsPage)
return slices.Clone(all[from:min(len(all), from+recentPullsPage)]), g.err
}
func (g *fakeGH) batchBot() string { return g.bot }
func (g *fakeGH) comments(int) ([]issueComment, error) {
defer func() {
if f := g.after["comments"]; f != nil {
f()
}
}()
return slices.Clone(g.issueComments), nil
}
func (g *fakeGH) createComment(number int, bodyFile string) error {
if err := g.write(number, "a comment"); err != nil {
return err
}
if err := g.inject("createComment"); err != nil {
return err
}
g.commented = append(g.commented, number)
defer func() {
if f := g.after["createComment"]; f != nil {
f()
}
}()
data, err := os.ReadFile(bodyFile)
var c issueComment
c.ID, c.Body = int64(1000+len(g.issueComments)), string(data)
@@ -302,21 +404,83 @@ func (g *fakeGH) permission(login string) (string, error) {
func (g *fakeGH) openPulls(base string) ([]pullRequest, error) {
var out []pullRequest
for _, pr := range g.prs {
if pr.Base.Ref == base {
out = append(out, pr)
if pr.Base.Ref == base && (pr.State == "" || pr.State == "open") {
out = append(out, g.live(pr))
}
}
return out, g.err
}
func (g *fakeGH) createPull(base, head, title, bodyFile string) error {
// createPull opens the pull request on the fake, as the maintainer, at the
// head the remote has, numbered nextPR.
func (g *fakeGH) createPull(base, head, title, bodyFile string) (int, string, error) {
if err := g.inject("createPull"); err != nil {
return 0, "", err
}
g.created = append(g.created, base+" "+head+" "+title)
return g.readBody(bodyFile)
if err := g.readBody(bodyFile); err != nil {
return 0, "", err
}
n := cmp.Or(g.nextPR, 90)
var pr pullRequest
pr.Number, pr.Body, pr.State = n, g.bodies[len(g.bodies)-1], "open"
pr.HTMLURL = fmt.Sprintf("https://github.com/%s/pull/%d", upstreamRepo, n)
pr.User.Login, pr.User.Type = "maintainer", "User"
pr.Head.Ref = head
if g.remoteHead != nil {
pr.Head.SHA = g.remoteHead(head)
}
pr.Head.Repo = &struct {
FullName string `json:"full_name"`
}{upstreamRepo}
pr.Base.Ref, pr.Base.Repo.FullName = base, upstreamRepo
g.prs = append(slices.DeleteFunc(g.prs, func(p pullRequest) bool { return p.Number == n }), pr)
return n, pr.HTMLURL, nil
}
func (g *fakeGH) editPull(number int, title, bodyFile string) error {
if err := g.write(number, "an edit"); err != nil {
return err
}
if err := g.inject("editPull"); err != nil {
return err
}
g.edited = append(g.edited, fmt.Sprintf("#%d %s", number, title))
return g.readBody(bodyFile)
if err := g.readBody(bodyFile); err != nil {
return err
}
if pr := g.find(number); pr != nil {
pr.Body = g.bodies[len(g.bodies)-1]
}
if f := g.after["editPull"]; f != nil {
f()
}
return nil
}
func (g *fakeGH) closePull(number int) error {
if err := g.write(number, "a close"); err != nil {
return err
}
if err := g.inject("closePull"); err != nil {
return err
}
if pr := g.find(number); pr != nil {
pr.State = "closed"
}
if f := g.after["closePull"]; f != nil {
f()
}
return nil
}
func (g *fakeGH) find(n int) *pullRequest {
for i := range g.prs {
if g.prs[i].Number == n {
return &g.prs[i]
}
}
return nil
}
func (g *fakeGH) readBody(path string) error {
@@ -338,18 +502,55 @@ func (f *fixture) batchPR(n int, base, branch, body string) pullRequest {
FullName string `json:"full_name"`
}{upstreamRepo}
pr.Base.Ref, pr.Base.Repo.FullName = base, upstreamRepo
pr.State = "open"
return pr
}
// exec runs the tool in the fixture with gh faked, on the fixture's date.
// botBatchPR is batchPR opened by the bot the scheduled batch runs as.
func (f *fixture) botBatchPR(gh *fakeGH, n int, base, branch, body string) pullRequest {
f.t.Helper()
gh.bot = defaultBatchBot
pr := f.batchPR(n, base, branch, body)
pr.User.Login, pr.User.Type, pr.AuthorAssociation = defaultBatchBot, "Bot", "CONTRIBUTOR"
return pr
}
// mergeBatch merges the open batch into release-1.1 with a merge commit, as
// GitHub does, and deletes its branch, as the repository's
// delete_branch_on_merge does. The pull request stays, merged, with its body
// and comments.
func mergeBatch(f *fixture, gh *fakeGH) {
f.t.Helper()
f.git("fetch", "-q", "origin")
f.git("switch", "-q", "--detach", "origin/release-1.1")
f.git("merge", "-q", "--no-ff", "-m", "Merge pull request #90 from cozystack/"+batchBranch1, "origin/"+batchBranch1)
f.git("push", "-q", "origin", "HEAD:refs/heads/release-1.1", ":refs/heads/"+batchBranch1)
f.git("switch", "-q", "main")
for i := range gh.prs {
if gh.prs[i].Number == 90 {
at := "2026-10-26T12:00:00Z"
gh.prs[i].State, gh.prs[i].MergedAt = "closed", &at
}
}
}
// exec runs the tool in the fixture with gh faked, on the fixture's date, and
// fails the test when the tool wrote to a pull request that is not open.
func (f *fixture) exec(gh *fakeGH, args ...string) (int, string, string) {
f.t.Helper()
if gh == nil {
gh = &fakeGH{}
}
if gh.remoteHead == nil {
gh.remoteHead = f.remoteHead
}
var stdout, stderr bytes.Buffer
a := &app{gh: gh, stdout: &stdout, stderr: &stderr, now: func() time.Time { return f.now }}
a := &app{gh: gh, stdout: &stdout, stderr: &stderr, now: func() time.Time { return f.now }, tool: f.tool}
written := len(gh.closedWrites)
code := a.main(args)
if len(gh.closedWrites) > written {
f.t.Errorf("%v wrote to a pull request that is not open: %q", args, gh.closedWrites[written:])
}
return code, stdout.String(), stderr.String()
}
+18
View File
@@ -330,3 +330,21 @@ func TestFold(t *testing.T) {
}
})
}
// A resolution of an original a maintainer dropped from the line is refused:
// the drop is the decision that it does not go there.
func TestFoldRefusesADroppedOriginal(t *testing.T) {
w := newFoldWorld(t)
sha := strings.Repeat("a", 40)
rec := strings.Replace(v2(""), `"dropped":[{"original":8,"merge":"`+sha+`"`, `"dropped":[{"original":5,"merge":"`+w.mc5+`"`, 1)
rec = strings.Replace(rec, `"deferred":[{"original":5,"commit":"`+sha+`","files":["app/five.txt"]},`, `"deferred":[`, 1)
rec = strings.Replace(rec, `"stale":[9]`, `"stale":[]`, 1)
w.gh.prs[0].Body = "text\n\n" + deferredFence + "\n" + rec + "\n```\n"
if _, err := parseRecord(w.gh.prs[0].Body, notifyLine); err != nil {
t.Fatal(err)
}
code, stdout, stderr := w.f.exec(w.gh, "fold", "--line", "release-1.1", "--original", "5", "--commit", w.resolution())
if code != 1 || !strings.Contains(stdout, "#5 is not one the batch takes: a maintainer's drop, dropped on #90: needs #4") {
t.Errorf("fold = %d, %q, %s; want a refusal naming the drop", code, stdout, stderr)
}
}
+88 -8
View File
@@ -21,12 +21,21 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
)
// openPullsCap is the page size of the open pull request listing. The REST
// listing stops at it in silence, so a full page is an error, not an answer.
const openPullsCap = 100
// recentPullsPage is the page size of the listing of every pull request
// against a line, newest first, and recentPullsPages how many pages of it a
// search for the line's record reads.
const (
recentPullsPage = 100
recentPullsPages = 10
)
// searchResultCap is the most results GitHub search returns for one query,
// however far it is paginated. gh pr list answers a --label filter through
// search, so a listing that long has to be taken as cut short.
@@ -43,6 +52,7 @@ var (
batchBranchRE = regexp.MustCompile(`^backport-batch/(release-\d+\.\d+)-(\d{8})$`)
loginRE = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$`)
botLoginRE = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\[bot\]$`)
pullURLRE = regexp.MustCompile(`^https://[^/\s]+/[^/\s]+/[^/\s]+/pull/([1-9][0-9]{0,8})$`)
)
// batchBotEnv names the GitHub App bot the scheduled batch opens its pull
@@ -66,10 +76,12 @@ func batchBotLogin(lookup func(string) (string, bool)) string {
// pullRequest is the REST view of a pull request: unlike gh pr list, it
// carries the author's association with the repository.
type pullRequest struct {
Number int `json:"number"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
AuthorAssociation string `json:"author_association"`
Number int `json:"number"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
State string `json:"state"`
MergedAt *string `json:"merged_at"`
AuthorAssociation string `json:"author_association"`
User struct {
Login string `json:"login"`
Type string `json:"type"`
@@ -102,13 +114,20 @@ type issueComment struct {
// github is the part of GitHub the tool talks to: gh outside tests.
type github interface {
openPulls(base string) ([]pullRequest, error)
// recentPulls is one page of every pull request against base, open,
// merged or closed, newest first.
recentPulls(base string, page int) ([]pullRequest, error)
// permission is login's permission on the repository as GitHub's
// legacy field gives it: admin, write, read or none.
permission(login string) (string, error)
// labelled lists the pull requests merged into main that carry label.
labelled(label string) ([]int, error)
createPull(base, head, title, bodyFile string) error
// pull reads one pull request as it is now.
pull(number int) (pullRequest, error)
// createPull opens the draft and returns its number and URL.
createPull(base, head, title, bodyFile string) (int, string, error)
editPull(number int, title, bodyFile string) error
closePull(number int) error
comments(number int) ([]issueComment, error)
createComment(number int, bodyFile string) error
editComment(id int64, bodyFile string) error
@@ -136,6 +155,38 @@ func (ghCLI) openPulls(base string) ([]pullRequest, error) {
return prs, nil
}
func (ghCLI) recentPulls(base string, page int) ([]pullRequest, error) {
out, err := capture("gh", "api", "-X", "GET", "repos/{owner}/{repo}/pulls",
"-f", "state=all", "-f", "base="+base, "-f", "sort=created", "-f", "direction=desc",
"-f", "per_page="+strconv.Itoa(recentPullsPage), "-f", "page="+strconv.Itoa(page))
if err != nil {
return nil, err
}
var prs []pullRequest
if err := json.Unmarshal([]byte(out), &prs); err != nil {
return nil, fmt.Errorf("reading the pull requests against %s: %w", base, err)
}
return prs, nil
}
// permCache asks GitHub for a login's permission once per command: the open
// batch's author is asked when it is chosen and again when its record is.
type permCache struct {
github
perms map[string]string
}
func (c *permCache) permission(login string) (string, error) {
if p, ok := c.perms[login]; ok {
return p, nil
}
p, err := c.github.permission(login)
if err == nil {
c.perms[login] = p
}
return p, err
}
func (ghCLI) permission(login string) (string, error) {
if !loginRE.MatchString(login) {
return "", fmt.Errorf("%q is not a GitHub login", login)
@@ -175,9 +226,33 @@ func (ghCLI) labelled(label string) ([]int, error) {
return numbers, nil
}
func (ghCLI) createPull(base, head, title, bodyFile string) error {
_, err := capture("gh", "pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", bodyFile)
return err
func (ghCLI) pull(number int) (pullRequest, error) {
var pr pullRequest
out, err := capture("gh", "api", "repos/{owner}/{repo}/pulls/"+strconv.Itoa(number))
if err != nil {
return pr, err
}
if err := json.Unmarshal([]byte(out), &pr); err != nil {
return pr, fmt.Errorf("reading pull request #%d: %w", number, err)
}
if pr.Number != number {
return pr, fmt.Errorf("asked for pull request #%d, GitHub answered with #%d", number, pr.Number)
}
return pr, nil
}
func (ghCLI) createPull(base, head, title, bodyFile string) (int, string, error) {
out, err := capture("gh", "pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", bodyFile)
if err != nil {
return 0, "", err
}
url := strings.TrimSpace(out)
m := pullURLRE.FindStringSubmatch(url)
if m == nil {
return 0, "", fmt.Errorf("gh pr create printed %q, not the URL of a pull request", url)
}
n, err := strconv.Atoi(m[1])
return n, url, err
}
func (ghCLI) editPull(number int, title, bodyFile string) error {
@@ -185,6 +260,11 @@ func (ghCLI) editPull(number int, title, bodyFile string) error {
return err
}
func (ghCLI) closePull(number int) error {
_, err := capture("gh", "pr", "close", strconv.Itoa(number))
return err
}
func (ghCLI) comments(number int) ([]issueComment, error) {
out, err := capture("gh", "api", "--paginate", "--slurp", "repos/{owner}/{repo}/issues/"+strconv.Itoa(number)+"/comments")
if err != nil {
+3
View File
@@ -202,6 +202,9 @@ func TestCarriedByTheBatch(t *testing.T) {
{name: "the batch's bot", edit: func(pr *pullRequest, gh *fakeGH) {
gh.bot = "cozystack-ci[bot]"
pr.User.Login, pr.User.Type, pr.AuthorAssociation = "cozystack-ci[bot]", "Bot", "CONTRIBUTOR"
// The bot writes the record into every body it opens.
l, _ := parseLine("release-1.1")
pr.Body = deferredBlock(l, nil, nil, nil)
}, carried: true, notAsked: true},
{name: "the batch's bot when no bot is configured", edit: func(pr *pullRequest, _ *fakeGH) {
pr.User.Login, pr.User.Type = "cozystack-ci[bot]", "Bot"
+19 -13
View File
@@ -207,29 +207,35 @@ func mainMerges(base, mainHead string) ([]*original, []string, error) {
var merges []*original
var other []string
for _, m := range list {
// The subject is the first paragraph, its lines joined, as git reads it.
msg := strings.TrimRight(msgs[m.oid].message, "\n")
subject, body, _ := strings.Cut(msg, "\n\n")
subject = strings.ReplaceAll(subject, "\n", " ")
o := &original{merge: m.oid, parents: m.parents, merged: msgs[m.oid].committed}
if pr := mergePRRE.FindStringSubmatch(subject); pr != nil {
o.number, _ = strconv.Atoi(pr[1])
body = strings.TrimLeft(body, "\n")
o.title, body, _ = strings.Cut(body, "\n")
} else if pr := prNumberRE.FindStringSubmatchIndex(subject); pr != nil {
o.number, _ = strconv.Atoi(subject[pr[2]:pr[3]])
o.title = strings.TrimSpace(subject[:pr[0]])
}
o.number, o.title, o.body = mergedPR(msgs[m.oid].message)
if o.number <= 0 || o.parents > 2 {
other = append(other, m.oid)
continue
}
o.body = strings.TrimLeft(body, "\n")
merges = append(merges, o)
}
return merges, other, nil
}
// mergedPR reads the pull request a commit on main's first-parent line
// merged from its message, with its title and body; 0 when it names none.
func mergedPR(message string) (int, string, string) {
// The subject is the first paragraph, its lines joined, as git reads it.
subject, body, _ := strings.Cut(strings.TrimRight(message, "\n"), "\n\n")
subject = strings.ReplaceAll(subject, "\n", " ")
number, title := 0, ""
if pr := mergePRRE.FindStringSubmatch(subject); pr != nil {
number, _ = strconv.Atoi(pr[1])
body = strings.TrimLeft(body, "\n")
title, body, _ = strings.Cut(body, "\n")
} else if pr := prNumberRE.FindStringSubmatchIndex(subject); pr != nil {
number, _ = strconv.Atoi(subject[pr[2]:pr[3]])
title = strings.TrimSpace(subject[:pr[0]])
}
return number, title, strings.TrimLeft(body, "\n")
}
// prCommits lists what an original brings to a line, one pick each: for a
// merged pull request every commit of its own, oldest first, leaving out the
// merges that brought main into its branch, whose content main already has;
+83 -29
View File
@@ -34,10 +34,13 @@ Usage:
backport-batch run --line release-X.Y [--onto SHA] [--date YYYY-MM-DD] [--no-workflow-files] [--push] [--policy REF]
backport-batch run --line release-X.Y --continue [--no-workflow-files] [--push] [--policy REF]
backport-batch run --line release-X.Y --defer N[,N] --reason TEXT [--push] [--policy REF]
backport-batch run --line release-X.Y --drop N[,N] --reason TEXT [--push] [--policy REF]
backport-batch run --line release-X.Y --undrop N[,N] [--push] [--policy REF]
backport-batch check --line release-X.Y [--cut] [--policy REF]
backport-batch fold --line release-X.Y --original N --commit SHA [--policy REF]
backport-batch verify --line release-X.Y [--head SHA]
backport-batch notify --line release-X.Y [--mention org/team|login] [--push]
backport-batch report --pr N [--id ID] [--json]
backport-batch lines [--policy REF]
run Builds backport-batch/<line>-<yyyymmdd> in this working tree, off
@@ -53,7 +56,13 @@ run Builds backport-batch/<line>-<yyyymmdd> in this working tree, off
--defer rebuilds the open batch off origin/<line> without those
originals and records them as deferred, for --reason: the batch keeps
them out, the next one tries them again. --push replaces the branch,
leased on the head the rebuild started from.
leased on the head the rebuild started from; when nothing is left it
closes the batch PR instead, after recording why in it.
--drop drops originals from the line for --reason: the line's record
keeps them out of this batch and every later one. One the open batch
carries is taken off it as --defer would; otherwise only the record
changes. --undrop withdraws a drop. Both need an open batch PR whose
record they change.
check Exits 1 while a change the policy takes is neither on the line nor on
the open batch PR, 0 when nothing is, 2 when it cannot tell. With --cut,
the gate before an rc or a patch is cut, it also exits 1 while a change
@@ -69,11 +78,18 @@ notify Prints what waits on the open batch PR for a maintainer: a batch
open for 3 days or more, its deferred conflicts, and the originals
that change workflows. --push keeps one comment on the PR that says
what waits, mentioning --mention.
report Prints a report batch PR N keeps: the one its body carries, or the
one --id names, read from its comments or its body. --json prints the
report as it is stored. It writes nothing, so it reads merged and
closed batches too.
lines Prints the release lines the batch runs for, newest first.
The policy is docs/release/backports.md. run, check, fold and lines read its
policy block from that page on origin/main, or on --policy REF. Every command
but notify, which reads only GitHub, fetches origin first.
but notify and report fetches origin first. notify reads only GitHub; report
reads GitHub, and for a report that keeps drops also this clone's history,
which has to hold the main and line heads the report was made from: report
never fetches on its own.
BACKPORT_BATCH_BOT names the bot the scheduled batch writes as,
<app>[bot], cozystack-ci[bot] when unset: a batch pull request or a notify
@@ -93,15 +109,18 @@ type app struct {
now func() time.Time
// policyRef is where the policy page is read from; origin/main when empty.
policyRef string
// tool is the commit backport-batch was built from, for the record.
tool string
}
func main() {
a := &app{gh: ghCLI{bot: batchBotLogin(os.LookupEnv)}, stdout: os.Stdout, stderr: os.Stderr, now: time.Now}
a := &app{gh: ghCLI{bot: batchBotLogin(os.LookupEnv)}, stdout: os.Stdout, stderr: os.Stderr, now: time.Now, tool: toolVersion()}
os.Exit(a.main(os.Args[1:]))
}
func (a *app) main(args []string) int {
a.stdout, a.stderr = textWriter{a.stdout}, textWriter{a.stderr}
a.gh = &permCache{github: a.gh, perms: map[string]string{}}
if len(args) == 0 {
fmt.Fprint(a.stderr, usageText)
return 2
@@ -120,6 +139,8 @@ func (a *app) main(args []string) int {
return a.verify(args[1:])
case "notify":
return a.notify(args[1:])
case "report":
return a.report(args[1:])
case "lines":
return a.lines(args[1:])
case "-h", "--help", "help":
@@ -198,11 +219,24 @@ type batch struct {
newer *line
lineHead string
mainHead string
base string
other []string
verdicts []*verdict
pr *pullRequest
prHead string
// newerHead is the newer line's head, and policyAt the commit the
// policy was read from.
newerHead string
policyAt string
base string
other []string
verdicts []*verdict
pr *pullRequest
prHead string
// picks is what the open batch pull request carries beyond the line.
picks *history
// src is the line's record and where it was found, and drops its drops
// that name an original of this batch.
src recordSource
drops map[int]droppedEntry
// lineHist and newerHist are what the line and the newer line carry,
// read once for every original judged.
lineHist, newerHist *history
// start is when the line started under the policy, zero for its fork point.
start time.Time
// textUntil is the batch-only day: what a commit's text says counts as
@@ -224,18 +258,20 @@ func fetch() error {
return err
}
func (a *app) policy(mainHead string) (*policy, error) {
// policy reads the policy and says from which commit.
func (a *app) policy(mainHead string) (*policy, string, error) {
at := mainHead
if a.policyRef != "" {
if strings.HasPrefix(a.policyRef, "-") {
return nil, fmt.Errorf("--policy %q is not a ref", a.policyRef)
return nil, "", fmt.Errorf("--policy %q is not a ref", a.policyRef)
}
var err error
if at, err = resolve(a.policyRef); err != nil {
return nil, err
return nil, "", err
}
}
return readPolicy(at)
pol, err := readPolicy(at)
return pol, at, err
}
// prepare reads the line, main and the open batch pull request, each pinned to
@@ -246,7 +282,7 @@ func (a *app) prepare(l line) (*batch, error) {
if b.mainHead, err = resolve("refs/remotes/" + remote + "/main"); err != nil {
return nil, err
}
pol, err := a.policy(b.mainHead)
pol, policyAt, err := a.policy(b.mainHead)
if err != nil {
return nil, err
}
@@ -260,7 +296,7 @@ func (a *app) prepare(l line) (*batch, error) {
if b.lineHead, err = resolve("refs/remotes/" + remote + "/" + l.branch()); err != nil {
return nil, err
}
b.start, b.textUntil = pol.starts[l.branch()], pol.batchOnly
b.start, b.textUntil, b.policyAt = pol.starts[l.branch()], pol.batchOnly, policyAt
out, err := git("merge-base", "--end-of-options", b.mainHead, b.lineHead)
if err != nil {
return nil, fmt.Errorf("%s shares no history with main: %w", l.branch(), err)
@@ -271,17 +307,14 @@ func (a *app) prepare(l line) (*batch, error) {
return nil, err
}
b.other = other
lineHist, err := readHistory(b.lineHead, b.mainHead, b.textUntil)
if err != nil {
if b.lineHist, err = readHistory(b.lineHead, b.mainHead, b.textUntil); err != nil {
return nil, err
}
var newerHist *history
if b.newer != nil {
newerHead, err := resolve("refs/remotes/" + remote + "/" + b.newer.branch())
if err != nil {
if b.newerHead, err = resolve("refs/remotes/" + remote + "/" + b.newer.branch()); err != nil {
return nil, err
}
if newerHist, err = readHistory(newerHead, b.mainHead, b.textUntil); err != nil {
if b.newerHist, err = readHistory(b.newerHead, b.mainHead, b.textUntil); err != nil {
return nil, err
}
}
@@ -300,29 +333,45 @@ func (a *app) prepare(l line) (*batch, error) {
if err != nil {
return nil, err
}
var prPicks *history
if pr != nil {
b.pr = pr
if b.prHead, err = batchHead(pr); err != nil {
return nil, err
}
if prPicks, err = readPicks(b.prHead, b.lineHead, b.textUntil); err != nil {
if b.picks, err = readPicks(b.prHead, b.lineHead, b.textUntil); err != nil {
return nil, err
}
}
if b.src, err = findLineRecord(a.gh, l, pr); err != nil {
return nil, err
}
if b.drops, err = b.bindDrops(merges); err != nil {
return nil, err
}
for _, o := range merges {
v, err := judge(o, labels[o.number], b.start, lineHist, b.newer, newerHist)
if err != nil {
v := &verdict{o: o, label: labels[o.number]}
if err := b.judge(v, b.drops); err != nil {
return nil, err
}
if v.rule == 0 && prPicks != nil {
v.onBatch, _ = prPicks.found(v.o, v.commits)
}
b.verdicts = append(b.verdicts, v)
}
return b, nil
}
// judge judges v again under drops, and finds it on the open batch when the
// policy takes it.
func (b *batch) judge(v *verdict, drops map[int]droppedEntry) error {
j, err := judge(v.o, v.label, b.start, b.lineHist, b.newer, b.newerHist, drops)
if err != nil {
return err
}
*v = *j
if v.rule == 0 && b.picks != nil {
v.onBatch, _ = b.picks.found(v.o, v.commits)
}
return nil
}
// overrides maps each pull request merged into main that carries a backport/
// label to the one that decides; backport/skip wins over backport/include.
func overrides(gh github) (map[int]string, error) {
@@ -344,8 +393,9 @@ func overrides(gh github) (map[int]string, error) {
// started under the policy after it branched takes nothing merged before that
// start. backport/skip keeps an original out whatever the rules say, and
// backport/include lifts the start and rules 2 to 4, but not what the line or
// the newer line carries.
func judge(o *original, label string, start time.Time, lineHist *history, newer *line, newerHist *history) (*verdict, error) {
// the newer line carries. A maintainer's drop keeps out what the line does
// not carry, include or not.
func judge(o *original, label string, start time.Time, lineHist *history, newer *line, newerHist *history, drops map[int]droppedEntry) (*verdict, error) {
v := &verdict{o: o, label: label}
if label == labelSkip {
v.rule, v.reason = ruleSkipLabel, labelSkip
@@ -388,6 +438,10 @@ func judge(o *original, label string, start time.Time, lineHist *history, newer
v.rule, v.reason = ruleOnLine, how
return v, nil
}
if d, ok := drops[o.number]; ok {
v.rule, v.reason = ruleDropped, fmt.Sprintf("dropped on #%d: %s", d.On, d.Reason)
return v, nil
}
if newer != nil {
how, err := newerHist.carries(o, v.commits)
if err != nil {
+16 -4
View File
@@ -70,6 +70,15 @@ func (a *app) notify(args []string) int {
if err != nil {
return a.fail(err)
}
// With no batch open there is nothing to report on and nowhere to write,
// but a record nobody can read is said all the same.
src, err := findLineRecord(a.gh, l, pr)
if err != nil {
return a.fail(err)
}
if c := src.closingOpen(); c != nil {
return a.fail(fmt.Errorf("batch pull request #%d is being closed; backport-batch run --line %s --push finishes it", c.PR, l.branch()))
}
if pr == nil {
return 0
}
@@ -77,9 +86,9 @@ func (a *app) notify(args []string) int {
if err != nil {
return a.fail(err)
}
deferred, err := parseDeferred(pr.Body, l)
if err != nil {
return a.fail(fmt.Errorf("batch pull request #%d: %w", pr.Number, err))
deferred := map[int]deferredEntry{}
if src.open {
deferred = src.rec.deferred()
}
var conflicts, waiting []int
for n, e := range deferred {
@@ -173,6 +182,9 @@ func (a *app) comment(pr *pullRequest, mention string, today time.Time, text []s
if err := f.Close(); err != nil {
return a.fail(err)
}
if err := a.stillOpen(pr.Number); err != nil {
return a.fail(err)
}
if ours != nil {
err = a.gh.editComment(ours.ID, f.Name())
} else {
@@ -198,7 +210,7 @@ func (a *app) lines(args []string) int {
if err != nil {
return a.fail(err)
}
pol, err := a.policy(mainHead)
pol, _, err := a.policy(mainHead)
if err != nil {
return a.fail(err)
}
+50 -2
View File
@@ -148,7 +148,8 @@ func TestNotifyFindsTheBotsComment(t *testing.T) {
ours.User.Login, ours.User.Type = bot, "Bot"
other := issueComment{ID: 8, Body: notifyMarker + "\nplanted"}
other.User.Login, other.User.Type = "renovate[bot]", "Bot"
pr := notifyPR("20261019", "")
// The bot writes the record into every body it opens.
pr := notifyPR("20261019", deferredBlock(notifyLine, nil, nil, nil))
pr.User.Login, pr.User.Type = bot, "Bot"
gh := &fakeGH{prs: []pullRequest{pr}, issueComments: []issueComment{ours, other}, bot: bot}
if code, stdout, stderr := notifyRun(gh, "--push"); code != 0 || !strings.Contains(stdout, "has been open since") {
@@ -172,7 +173,7 @@ func TestNotifyFailsOnAnUnreadablePermission(t *testing.T) {
}
const bot = "cozystack-ci[bot]"
pr := notifyPR("20261019", "")
pr := notifyPR("20261019", deferredBlock(notifyLine, nil, nil, nil))
pr.User.Login, pr.User.Type = bot, "Bot"
ours := issueComment{ID: 7, Body: notifyMarker + "\nearlier"}
ours.User.Login, ours.User.Type = "maintainer", "User"
@@ -203,6 +204,32 @@ func TestNotifyRefuses(t *testing.T) {
}
}
// notify reads the line's record the way the other commands do, and stays
// silent with no batch open, but a record it cannot read, or one that says
// its open batch is being closed, stops it.
func TestNotifyValidatesTheRecord(t *testing.T) {
sha := strings.Repeat("a", 40)
closingRecord := strings.Replace(v2(""), `"closing":null`, `"closing":{"pr":91,"branch":"backport-batch/release-1.1-20261002","head":"`+sha+`"}`, 1)
block := func(json string) string { return "text\n\n" + deferredFence + "\n" + json + "\n```\n" }
for _, tc := range []struct {
name string
pr pullRequest
code int
quiet bool
}{
{"a clean closed record", recordPR(91, "closed", "maintainer", block(v2(""))), 0, true},
{"a malformed record", recordPR(91, "closed", "maintainer", block(`{"line":`)), 2, false},
{"a closed record that was closing", recordPR(91, "closed", "maintainer", block(closingRecord)), 0, true},
{"an open record that is closing", recordPR(91, "open", "maintainer", block(closingRecord)), 2, false},
} {
gh := &fakeGH{prs: []pullRequest{tc.pr}}
code, stdout, stderr := notifyRun(gh, "--push")
if code != tc.code || tc.quiet && (stdout != "" || stderr != "") || len(gh.issueComments) != 0 {
t.Errorf("%s: notify = %d, %q, %q, %d comments; want %d and nothing written", tc.name, code, stdout, stderr, len(gh.issueComments), tc.code)
}
}
}
// The maintainer's example: with 1.6 and 1.7 released, release-1.8 joins them
// when it branches, and 1.8.0 drops 1.6.
func TestSupportedLines(t *testing.T) {
@@ -286,3 +313,24 @@ func TestRunWithNothingToPick(t *testing.T) {
t.Errorf("pushed %q and opened %q with nothing picked", f.remoteHead(batchBranch1), gh.created)
}
}
// notify writes its comment only after GitHub says the batch is still open,
// so a batch closed while notify reads it is not written to, whether the
// comment would be new or an update of its own.
func TestNotifyWritesNothingOnABatchClosedMeanwhile(t *testing.T) {
for _, update := range []bool{false, true} {
t.Run(map[bool]string{false: "a new comment", true: "an update"}[update], func(t *testing.T) {
gh := &fakeGH{prs: []pullRequest{notifyPR("20261023", recordBody(5))}}
if update {
ours := issueComment{ID: 7, Body: notifyMarker + "\nearlier"}
ours.User.Login, ours.User.Type = "maintainer", "User"
gh.issueComments = []issueComment{ours}
}
gh.after = map[string]func(){"comments": func() { gh.find(90).State = "closed" }}
code, _, stderr := notifyRun(gh, "--push")
if code != 2 || !strings.Contains(stderr, "batch pull request #90 is closed now; nothing is written to it") || len(gh.closedWrites) != 0 || update && gh.issueComments[0].Body != notifyMarker+"\nearlier" {
t.Errorf("notify = %d, closed writes %q, comments %+v; want 2 and nothing written:\n%s", code, gh.closedWrites, gh.issueComments, stderr)
}
})
}
}
+31 -3
View File
@@ -196,6 +196,13 @@ func TestGHCLI(t *testing.T) {
if want := "api -X GET repos/{owner}/{repo}/pulls -f state=open -f base=release-1.6 -f per_page=100"; called() != want {
t.Errorf("gh %q, want gh %q", called(), want)
}
write("[]")
if _, err := (ghCLI{}).recentPulls("release-1.6", 2); err != nil {
t.Fatal(err)
}
if want := "api -X GET repos/{owner}/{repo}/pulls -f state=all -f base=release-1.6 -f sort=created -f direction=desc -f per_page=100 -f page=2"; called() != want {
t.Errorf("gh %q, want gh %q", called(), want)
}
var page []string
for i := range openPullsCap {
page = append(page, fmt.Sprintf(`{"number":%d}`, i+1))
@@ -230,13 +237,34 @@ func TestGHCLI(t *testing.T) {
// A batch pull request carries no label: the release workflows act on a
// pull request cozystack-ci[bot] opened only when it has the release label.
write("")
if err := (ghCLI{}).createPull("release-1.7", batchBranch1, "fix(backport): batch", "/tmp/body.md"); err != nil {
t.Fatal(err)
write("https://github.com/cozystack/cozystack/pull/5300\n")
if n, url, err := (ghCLI{}).createPull("release-1.7", batchBranch1, "fix(backport): batch", "/tmp/body.md"); err != nil || n != 5300 || url != "https://github.com/cozystack/cozystack/pull/5300" {
t.Errorf("createPull = %d, %q, %v; want the pull request gh printed", n, url, err)
}
if want := "pr create --draft --base release-1.7 --head " + batchBranch1 + " --title fix(backport): batch --body-file /tmp/body.md"; called() != want {
t.Errorf("gh %q, want gh %q", called(), want)
}
write("Warning: 1 uncommitted change\n")
if _, _, err := (ghCLI{}).createPull("release-1.7", batchBranch1, "fix(backport): batch", "/tmp/body.md"); err == nil {
t.Error("createPull took output that is no pull request URL")
}
write(`{"number":90,"state":"closed","merged_at":"2026-10-26T12:00:00Z","head":{"ref":"x","sha":"y"}}`)
if pr, err := (ghCLI{}).pull(90); err != nil || pr.State != "closed" || pr.MergedAt == nil {
t.Errorf("pull = %+v, %v; want #90 merged", pr, err)
}
if want := "api repos/{owner}/{repo}/pulls/90"; called() != want {
t.Errorf("gh %q, want gh %q", called(), want)
}
if _, err := (ghCLI{}).pull(91); err == nil {
t.Error("pull took GitHub's answer about another pull request")
}
write("")
if err := (ghCLI{}).closePull(90); err != nil {
t.Fatal(err)
}
if want := "pr close 90"; called() != want {
t.Errorf("gh %q, want gh %q", called(), want)
}
write("")
if err := (ghCLI{}).editPull(90, "fix(backport): batch", "/tmp/body.md"); err != nil {
t.Fatal(err)
+440
View File
@@ -0,0 +1,440 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"slices"
"strings"
"time"
)
// A line's record is the backport-batch block of its newest batch pull
// request, read whatever that pull request's state and written only while it
// is open: a merged or closed pull request is never written again, so what it
// recorded stays what the line's later commands act on until a newer batch
// carries a record of its own.
// lineRecord is the one line of JSON in the block. Version 1, the block
// before it had a version, holds only line and deferred.
type lineRecord struct {
Version int `json:"version"`
Line string `json:"line"`
Built *builtFrom `json:"built,omitempty"`
Report string `json:"report,omitempty"`
Closing *closing `json:"closing"`
Deferred []deferredEntry `json:"deferred"`
Dropped []droppedEntry `json:"dropped"`
Stale []int `json:"stale"`
}
// builtFrom is what a classification was made of; batch is the head it
// describes.
type builtFrom struct {
Main string `json:"main"`
Line string `json:"line"`
Newer string `json:"newer"`
Policy string `json:"policy"`
Batch string `json:"batch"`
Tool string `json:"tool"`
Run string `json:"run"`
At string `json:"at"`
Options runOptions `json:"options"`
}
type runOptions struct {
NoWorkflowFiles bool `json:"noWorkflowFiles"`
Mode string `json:"mode"`
}
// The operations a record is written by: a run that assembles the batch, and
// the ones that only change what the record says about it.
const (
modeNew = "new"
modeContinue = "continue"
modeDefer = "defer"
modeDrop = "drop"
modeUndrop = "undrop"
modeClose = "close"
)
var modes = []string{modeNew, modeContinue, modeDefer, modeDrop, modeUndrop, modeClose}
// closing is written into the record of an open batch that the run is about
// to close: the head it froze, which the close and the branch's deletion are
// leased on.
type closing struct {
PR int `json:"pr"`
Branch string `json:"branch"`
Head string `json:"head"`
}
// droppedEntry is a maintainer's decision that an original stays off the
// line, bound to its merge commit on main.
type droppedEntry struct {
Original int `json:"original"`
Merge string `json:"merge"`
Reason string `json:"reason"`
On int `json:"on"`
Date string `json:"date"`
}
var (
toolRE = regexp.MustCompile(`^[0-9a-f]{40}(\+dirty)?$`)
reportIDRE = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// oneLineText is s as a run writes a reason: one line, cleaned and capped.
// A record holding anything else was not written by a run, and its text
// would reach a terminal, a body or a comment as someone else wrote it.
func oneLineText(s string) string { return capped(oneLine(clean(s, false)), titleLimit) }
// fencedLines lists the one line of each block that fence opens.
func fencedLines(body, fence string) ([]string, error) {
var blocks []string
lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
for i := 0; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) != fence {
continue
}
if i+2 >= len(lines) || strings.TrimSpace(lines[i+2]) != "```" {
return nil, fmt.Errorf("the %s block is not one line of JSON", fence)
}
blocks = append(blocks, lines[i+1])
i += 2
}
return blocks, nil
}
// decodeStrict reads exactly one JSON value into v, refusing a key v does not
// have and anything after the value.
func decodeStrict(data string, v any) error {
dec := json.NewDecoder(strings.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
var rest json.RawMessage
if dec.More() || dec.Decode(&rest) != io.EOF {
return errors.New("something follows the JSON value")
}
return nil
}
// parseRecord reads the line's record from a batch body: nil when the body
// has no block, an error when it has more than one or one it cannot vouch
// for whole.
func parseRecord(body string, l line) (*lineRecord, error) {
blocks, err := fencedLines(body, deferredFence)
switch {
case err != nil:
return nil, err
case len(blocks) == 0:
return nil, nil
case len(blocks) > 1:
return nil, fmt.Errorf("the body holds %d %s blocks", len(blocks), deferredFence)
}
var head struct {
Version *int `json:"version"`
}
if err := json.Unmarshal([]byte(blocks[0]), &head); err != nil {
return nil, fmt.Errorf("reading the %s block: %w", deferredFence, err)
}
version := 1
if head.Version != nil {
version = *head.Version
}
var rec lineRecord
switch version {
case 1:
var v1 struct {
Version int `json:"version"`
Line string `json:"line"`
Deferred []deferredEntry `json:"deferred"`
}
err = decodeStrict(blocks[0], &v1)
rec = lineRecord{Version: 1, Line: v1.Line, Deferred: v1.Deferred}
case 2:
err = decodeStrict(blocks[0], &rec)
default:
return nil, fmt.Errorf("the %s block is version %d, which this backport-batch does not read; it reads 1 and 2", deferredFence, version)
}
if err != nil {
return nil, fmt.Errorf("reading the %s block: %w", deferredFence, err)
}
if err := rec.validate(l); err != nil {
return nil, fmt.Errorf("the %s block %w", deferredFence, err)
}
return &rec, nil
}
// validate holds the record to what a run writes. Its errors read after
// "the block".
func (rec *lineRecord) validate(l line) error {
if rec.Line != l.branch() {
return fmt.Errorf("is for %q, not %s", rec.Line, l.branch())
}
seen := map[int]bool{}
once := func(n int) error {
if n <= 0 {
return fmt.Errorf("names original %d", n)
}
if seen[n] {
return fmt.Errorf("names #%d twice", n)
}
seen[n] = true
return nil
}
for _, d := range rec.Deferred {
if err := once(d.Original); err != nil {
return err
}
if d.Commit != "" && checkSHA(d.Commit) != nil {
return fmt.Errorf("names commit %q for #%d", d.Commit, d.Original)
}
// A conflict names the commit that failed and its files, a deferral
// its reason, and a wait its workflow files.
kinds := 0
for _, is := range []bool{d.Commit != "" || len(d.Files) > 0 && !d.Workflows && d.Reason == "", d.Reason != "", d.Workflows} {
if is {
kinds++
}
}
switch {
case d.Workflows && (d.Commit != "" || d.Reason != ""):
return fmt.Errorf("has #%d wait for a maintainer and defers it as well", d.Original)
case kinds != 1:
return fmt.Errorf("does not say whether #%d is a conflict, deferred for a reason or waiting", d.Original)
case d.Reason != "" && strings.TrimSpace(d.Reason) == "":
return fmt.Errorf("defers #%d for an empty reason", d.Original)
case d.Reason != oneLineText(d.Reason):
return fmt.Errorf("defers #%d for %q, which is no reason a run writes", d.Original, d.Reason)
case !slices.Equal(d.Files, cleanFiles(d.Files)):
return fmt.Errorf("names files of #%d that are not as a run writes them: %q", d.Original, d.Files)
case d.More < 0:
return fmt.Errorf("counts %d more files for #%d", d.More, d.Original)
}
}
for _, d := range rec.Dropped {
if err := once(d.Original); err != nil {
return err
}
if _, err := time.Parse(time.DateOnly, d.Date); err != nil || checkSHA(d.Merge) != nil || strings.TrimSpace(d.Reason) == "" || d.On <= 0 {
return fmt.Errorf("drops #%d with a merge, reason, pull request or date it cannot have", d.Original)
}
if d.Reason != oneLineText(d.Reason) {
return fmt.Errorf("drops #%d for %q, which is no reason a run writes", d.Original, d.Reason)
}
}
staleSeen := map[int]bool{}
for _, n := range rec.Stale {
if n <= 0 || staleSeen[n] {
return fmt.Errorf("lists %d as still on the batch more than once or not at all", n)
}
staleSeen[n] = true
}
if rec.Version < 2 {
return nil
}
if b := rec.Built; b == nil {
return errors.New("does not say what it was built from")
} else if err := b.validate(); err != nil {
return err
}
if !reportIDRE.MatchString(rec.Report) {
return fmt.Errorf("names report %q", rec.Report)
}
if c := rec.Closing; c != nil {
m := batchBranchRE.FindStringSubmatch(c.Branch)
if c.PR <= 0 || m == nil || m[1] != l.branch() || checkSHA(c.Head) != nil {
return fmt.Errorf("closes #%d on %q at %q, which is no batch of %s", c.PR, c.Branch, c.Head, l.branch())
}
}
return nil
}
func (b *builtFrom) validate() error {
for _, sha := range []string{b.Main, b.Line, b.Policy, b.Batch} {
if checkSHA(sha) != nil {
return fmt.Errorf("says it was built from %q", sha)
}
}
if b.Newer != "" && checkSHA(b.Newer) != nil {
return fmt.Errorf("says it was built from %q", b.Newer)
}
if b.Tool != "" && !toolRE.MatchString(b.Tool) {
return fmt.Errorf("names tool %q", b.Tool)
}
if b.Run != "" && !runURLRE.MatchString(b.Run) {
return fmt.Errorf("names run %q", b.Run)
}
if _, err := time.Parse(time.RFC3339, b.At); err != nil {
return fmt.Errorf("was built at %q", b.At)
}
if !slices.Contains(modes, b.Options.Mode) {
return fmt.Errorf("was written by %q, which is no operation of backport-batch", b.Options.Mode)
}
return nil
}
var runURLRE = regexp.MustCompile(`^https://[^/\s]+/[^/\s]+/[^/\s]+/actions/runs/[0-9]+(/attempts/[0-9]+)?$`)
// recordBlock is the block that holds rec. One line of JSON: encoding/json
// escapes newlines and <, > and &, so nothing in a file name or a reason can
// end the fence or open HTML.
func recordBlock(rec lineRecord) string {
data, _ := json.Marshal(rec)
return deferredFence + "\n" + string(data) + "\n```"
}
// savedNoWorkflowFiles is the --no-workflow-files the open batch was last
// assembled with, which an operation that only changes the record keeps. A
// record from before it was saved says so by listing a wait.
func (s recordSource) savedNoWorkflowFiles() bool {
if !s.open || s.rec == nil {
return false
}
if s.rec.Built != nil {
return s.rec.Built.Options.NoWorkflowFiles
}
return slices.ContainsFunc(s.rec.Deferred, func(d deferredEntry) bool { return d.Workflows })
}
// dropped is what the record drops, which every later record keeps.
func (rec *lineRecord) dropped() []droppedEntry {
if rec == nil {
return nil
}
return slices.Clone(rec.Dropped)
}
// deferred maps the originals the record defers to their entries.
func (rec *lineRecord) deferred() map[int]deferredEntry {
out := map[int]deferredEntry{}
if rec != nil {
for _, d := range rec.Deferred {
out[d.Original] = d
}
}
return out
}
// recordSource is where a line's record was found.
type recordSource struct {
// carrier is the pull request whose block is the record, nil when the
// line has none.
carrier *pullRequest
// open says the carrier is the open batch, so the record may be written.
open bool
rec *lineRecord
// code is the open batch pull request, if any. One whose body someone
// wrote by hand is the batch the line builds on but carries no record.
code *pullRequest
}
// closingOpen is the close the record says is under way on a batch that is
// still open, or nil.
func (s recordSource) closingOpen() *closing {
if !s.open || s.rec == nil {
return nil
}
return s.rec.Closing
}
func prState(pr *pullRequest) string {
switch {
case pr.MergedAt != nil:
return "merged"
case pr.State == "" || pr.State == "open":
return "open"
case pr.State == "closed":
return "closed"
}
return fmt.Sprintf("in state %q", pr.State)
}
func hasRecord(body string) bool {
return slices.ContainsFunc(strings.Split(body, "\n"), func(ln string) bool { return strings.TrimSpace(ln) == deferredFence })
}
// findLineRecord finds the line's record: the block of the newest pull
// request opened from a batch branch of this repository for the line by an
// author the batch trusts, open, merged or closed. open is the open batch
// pull request batchPull chose, or nil. Anything that leaves in doubt which
// record is the newest is an error, never a fall back to an older one: a
// newer block by an author the batch does not trust, a block the bot wrote
// and someone removed, a record it cannot read, a closed record newer than
// the open batch, or more pull requests than it reads.
func findLineRecord(gh github, l line, open *pullRequest) (recordSource, error) {
src := recordSource{code: open}
for page := 1; src.carrier == nil; page++ {
if page > recentPullsPages {
return src, fmt.Errorf("none of the %d newest pull requests against %s is a batch pull request with a record, so the line's record cannot be told apart from none", recentPullsPages*recentPullsPage, l.branch())
}
prs, err := gh.recentPulls(l.branch(), page)
if err != nil {
return src, err
}
for i := range prs {
pr := &prs[i]
m := batchBranchRE.FindStringSubmatch(pr.Head.Ref)
if m == nil || m[1] != l.branch() || pr.Base.Ref != l.branch() || pr.Head.Repo == nil || pr.Head.Repo.FullName != pr.Base.Repo.FullName {
continue
}
if open != nil && pr.Number == open.Number {
pr = open
}
if !hasRecord(pr.Body) {
if pr.User.Type == "Bot" && gh.batchBot() != "" && pr.User.Login == gh.batchBot() {
return src, fmt.Errorf("batch pull request #%d (%s) was opened by %s but carries no %s block; someone removed it, so the line's record is unknown", pr.Number, prState(pr), pr.User.Login, deferredFence)
}
continue
}
why, err := trusted(gh, *pr)
if err != nil {
return src, fmt.Errorf("batch pull request #%d: %w", pr.Number, err)
}
if why != "" {
return src, fmt.Errorf("batch pull request #%d (%s) carries a %s block but was %s; an older record is not taken in its place", pr.Number, prState(pr), deferredFence, why)
}
rec, err := parseRecord(pr.Body, l)
if err != nil {
return src, fmt.Errorf("batch pull request #%d (%s): %w", pr.Number, prState(pr), err)
}
src.carrier, src.rec = pr, rec
break
}
if len(prs) < recentPullsPage {
break
}
}
switch c := src.carrier; {
case open != nil && hasRecord(open.Body) && (c == nil || c.Number < open.Number):
return src, fmt.Errorf("GitHub does not list the open batch pull request #%d among the pull requests against %s; run again", open.Number, l.branch())
case open != nil && hasRecord(open.Body) && c.Number > open.Number:
return src, fmt.Errorf("batch pull request #%d (%s) carries a record newer than the open batch pull request #%d; close one of them by hand", c.Number, prState(c), open.Number)
case open != nil && c == open:
src.open = true
case c != nil && prState(c) == "open":
return src, fmt.Errorf("batch pull request #%d carries the line's record and is open, but is not the batch this run builds on", c.Number)
}
return src, nil
}
+368
View File
@@ -0,0 +1,368 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"strings"
"testing"
)
// The records the live batches carried before records had a version, as
// GitHub returned them on 2026-11-09, read as version 1.
func TestRecordReadsVersion1(t *testing.T) {
for line, block := range map[string]string{
"release-1.7": `{"line":"release-1.7","deferred":[{"original":4756,"commit":"647407545d630291cfe2a20880cce45af22edf0c","files":["packages/system/backupstrategy-controller/values.yaml"]},{"original":4867,"commit":"813c6180861efe1896c26d62a7afcbc1ff590ebc","files":[".github/renovate.json"]},{"original":5082,"commit":"e93db8848027450c2e23d21348409efa641cb6f1","files":["pkg/registry/core/tap/rest.go"]},{"original":4888,"files":[".github/workflows/cut-prerelease.yaml"],"workflows":true},{"original":4971,"files":[".github/workflows/e2e-proxmox.yaml"],"workflows":true}]}`,
"release-1.6": `{"line":"release-1.6","deferred":[{"original":5071,"commit":"3fa534f3cf6cb5774a183f0202f7f5c13e9ae211","files":["internal/operator/tapmaterializer_reconciler.go","internal/operator/tapmaterializer_reconciler_test.go"]},{"original":5086,"commit":"5265ee662c591db409c8ef1ed8bb457cf29f5c22","files":["packages/apps/tenant/templates/gateway.yaml"]},{"original":5087,"commit":"8d8179f20a2c10de03face1472435d5083cde38d","files":["internal/controller/applicationdefinition_helmreconciler.go","internal/controller/applicationdefinition_helmreconciler_test.go"]},{"original":5142,"files":[".github/workflows/pull-requests-release.yaml",".github/workflows/update-releasenotes.yaml"],"workflows":true}]}`,
} {
l, _ := parseLine(line)
rec, err := parseRecord("## What this PR does\n\ntext\n\n"+deferredFence+"\n"+block+"\n```\n\n### Release note\n", l)
if err != nil {
t.Fatalf("%s: parseRecord = %v", line, err)
}
waits, conflicts := 0, 0
for _, e := range rec.Deferred {
switch {
case e.Workflows:
waits++
case e.Commit != "":
conflicts++
}
}
if rec.Version != 1 || rec.Line != line || rec.Closing != nil || len(rec.Dropped) != 0 || waits+conflicts != len(rec.Deferred) || waits == 0 || conflicts == 0 {
t.Errorf("%s: record = %+v; want version 1 with its conflicts and waits", line, rec)
}
}
}
// v2 is a version 2 record with each value it may hold.
func v2(extra string) string {
sha := strings.Repeat("a", 40)
return `{"version":2,"line":"release-1.1","built":{"main":"` + sha + `","line":"` + sha + `","newer":"","policy":"` + sha + `","batch":"` + sha + `","tool":"` + sha + `+dirty","run":"","at":"2026-10-26T06:00:00Z","options":{"noWorkflowFiles":true,"mode":"continue"}},` +
`"report":"` + strings.Repeat("b", 64) + `","closing":null,"deferred":[{"original":5,"commit":"` + sha + `","files":["app/five.txt"]},{"original":6,"files":[],"reason":"fails the e2e"},{"original":7,"files":[".github/workflows/x.yaml"],"workflows":true}],` +
`"dropped":[{"original":8,"merge":"` + sha + `","reason":"needs #4","on":90,"date":"2026-10-26"}],"stale":[9]` + extra + `}`
}
// A record is read whole or not at all: anything it cannot vouch for stops
// the command rather than being half believed.
func TestRecordRefuses(t *testing.T) {
l, _ := parseLine("release-1.1")
block := func(json string) string { return "text\n\n" + deferredFence + "\n" + json + "\n```\n" }
if rec, err := parseRecord(block(v2("")), l); err != nil || rec.Version != 2 || len(rec.Dropped) != 1 || rec.Built.Options.Mode != "continue" {
t.Fatalf("parseRecord of a whole v2 record = %+v, %v", rec, err)
}
sha := strings.Repeat("a", 40)
for name, json := range map[string]string{
"version 4": strings.Replace(v2(""), `"version":2`, `"version":4`, 1),
"trailing data": v2("") + `{}`,
"trailing text": v2("") + ` x`,
"an original listed twice": strings.Replace(v2(""), `"original":8`, `"original":5`, 1),
"a needs entry with a commit": strings.Replace(v2(""), `{"original":6,"files":[],"reason":"fails the e2e"}`, `{"original":6,"commit":"`+sha+`","files":[],"needs":[4]}`, 1),
"an empty reason": strings.Replace(v2(""), `"reason":"fails the e2e"`, `"reason":""`, 1),
"an empty drop reason": strings.Replace(v2(""), `"reason":"needs #4"`, `"reason":" "`, 1),
"a bad tool": strings.Replace(v2(""), `+dirty"`, `-dirty"`, 1),
"an unknown mode": strings.Replace(v2(""), `"mode":"continue"`, `"mode":"rebase"`, 1),
"a reason with a commit": strings.Replace(v2(""), `"reason":"fails the e2e"`, `"reason":"x","commit":"`+sha+`"`, 1),
"a short merge": strings.Replace(v2(""), `"merge":"`+sha+`"`, `"merge":"abc"`, 1),
"a bad date": strings.Replace(v2(""), `"date":"2026-10-26"`, `"date":"26.10.2026"`, 1),
"a bad time": strings.Replace(v2(""), `"at":"2026-10-26T06:00:00Z"`, `"at":"yesterday"`, 1),
"a short report": strings.Replace(v2(""), strings.Repeat("b", 64), "bbbb", 1),
"a closing on another line": strings.Replace(v2(""), `"closing":null`, `"closing":{"pr":90,"branch":"backport-batch/release-1.0-20261026","head":"`+sha+`"}`, 1),
"a closing without a head": strings.Replace(v2(""), `"closing":null`, `"closing":{"pr":90,"branch":"`+batchBranch1+`","head":""}`, 1),
"a negative stale": strings.Replace(v2(""), `"stale":[9]`, `"stale":[-9]`, 1),
"an unknown key": v2(`,"parked":[]`),
"an entry of no kind": strings.Replace(v2(""), `{"original":6,"files":[],"reason":"fails the e2e"}`, `{"original":6,"files":[]}`, 1),
"another line": strings.Replace(v2(""), `"line":"release-1.1"`, `"line":"release-1.0"`, 1),
"an escape in a reason": strings.Replace(v2(""), `"reason":"fails the e2e"`, `"reason":"fails\u001b[2J the e2e"`, 1),
"a newline in a reason": strings.Replace(v2(""), `"reason":"fails the e2e"`, `"reason":"fails\nwarning: forged"`, 1),
"an escape in a drop": strings.Replace(v2(""), `"reason":"needs #4"`, `"reason":"needs\u001b]0;x\u0007 #4"`, 1),
"a newline in a drop": strings.Replace(v2(""), `"reason":"needs #4"`, `"reason":"needs #4\nnote: forged"`, 1),
"a reason past its length": strings.Replace(v2(""), `"reason":"needs #4"`, `"reason":"`+strings.Repeat("x", titleLimit+1)+`"`, 1),
"a bidi file name": strings.Replace(v2(""), `"files":["app/five.txt"]`, `"files":["app/\u202efive.txt"]`, 1),
} {
if _, err := parseRecord(block(json), l); err == nil {
t.Errorf("%s: parseRecord accepted it", name)
}
}
closing := strings.Replace(v2(""), `"closing":null`, `"closing":{"pr":90,"branch":"`+batchBranch1+`","head":"`+sha+`"}`, 1)
if rec, err := parseRecord(block(closing), l); err != nil || rec.Closing == nil || rec.Closing.PR != 90 {
t.Errorf("a record that is closing = %+v, %v", rec, err)
}
}
// recordPR is a batch pull request on release-1.1 carrying body, numbered n,
// in state, opened by login.
func recordPR(n int, state, login, body string) pullRequest {
pr := notifyPR("20261026", body)
pr.Number, pr.State = n, state
pr.Head.Ref = fmt.Sprintf("backport-batch/release-1.1-202610%02d", n%30+1)
pr.User.Login = login
if strings.HasSuffix(login, "[bot]") {
pr.User.Type = "Bot"
}
return pr
}
func recordBody(conflict int) string {
return "text\n\n" + deferredFence + "\n" + fmt.Sprintf(`{"line":"release-1.1","deferred":[{"original":%d,"commit":"%s","files":["a"]}]}`, conflict, strings.Repeat("a", 40)) + "\n```\n"
}
// The record is the block of the newest batch pull request that carries one,
// whatever its state, among those a trusted author opened from a batch branch
// of this repository; anything that leaves the newest record in doubt stops
// the command rather than falling back to an older one.
func TestFindLineRecord(t *testing.T) {
const bot = defaultBatchBot
other := func(n int) pullRequest {
pr := recordPR(n, "closed", "maintainer", "")
pr.Head.Ref = fmt.Sprintf("fix-%d", n)
return pr
}
fork := recordPR(92, "closed", "maintainer", recordBody(92))
fork.Head.Repo = &struct {
FullName string `json:"full_name"`
}{"someone/cozystack"}
cases := []struct {
name string
prs []pullRequest
open int // the open batch the caller found, or 0
bot string
carrier int // 0 for none
isOpen bool
refusal string
pages int
}{
{name: "the open batch first", prs: []pullRequest{recordPR(92, "open", "maintainer", recordBody(92)), recordPR(91, "closed", "maintainer", recordBody(91))}, open: 92, carrier: 92, isOpen: true},
{name: "the newest closed record", prs: []pullRequest{recordPR(91, "closed", bot, recordBody(91)), recordPR(90, "closed", bot, recordBody(90))}, bot: bot, carrier: 91},
{name: "a person's batch without a block is passed over", prs: []pullRequest{recordPR(92, "closed", "maintainer", "picked by hand"), recordPR(91, "closed", "maintainer", recordBody(91))}, carrier: 91},
{name: "the bot's batch without a block", prs: []pullRequest{recordPR(92, "closed", bot, "edited away"), recordPR(91, "closed", bot, recordBody(91))}, bot: bot, refusal: "carries no ```backport-batch block"},
{name: "a fork is no batch", prs: []pullRequest{fork, recordPR(91, "closed", "maintainer", recordBody(91))}, carrier: 91},
{name: "a newer record by someone untrusted", prs: []pullRequest{recordPR(92, "closed", "someone", recordBody(92)), recordPR(91, "closed", "maintainer", recordBody(91))}, refusal: "#92"},
{name: "a closed record newer than the open batch", prs: []pullRequest{recordPR(92, "closed", "maintainer", recordBody(92)), recordPR(91, "open", "maintainer", recordBody(91))}, open: 91, refusal: "newer than the open batch pull request #91"},
{name: "a record on the second page", prs: append(func() (out []pullRequest) {
for n := 1000; n < 1000+recentPullsPage; n++ {
out = append(out, other(n))
}
return out
}(), recordPR(91, "closed", "maintainer", recordBody(91))), carrier: 91, pages: 2},
{name: "ten full pages without a record", prs: func() (out []pullRequest) {
for n := 1000; n < 1000+10*recentPullsPage+1; n++ {
out = append(out, other(n))
}
return out
}(), refusal: "pull requests", pages: 10},
{name: "the bot's record when no bot is trusted", prs: []pullRequest{recordPR(91, "closed", bot, recordBody(91))}, refusal: "#91"},
{name: "a hand-written open batch beside a closed record", prs: []pullRequest{recordPR(92, "open", "maintainer", "picked by hand"), recordPR(91, "closed", "maintainer", recordBody(91))}, open: 92, carrier: 91},
{name: "no batch ever", prs: []pullRequest{other(5)}},
{name: "an unreadable record", prs: []pullRequest{recordPR(91, "closed", "maintainer", "text\n\n"+deferredFence+"\n{\n```\n")}, refusal: "#91 (closed)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l, _ := parseLine("release-1.1")
gh := &fakeGH{prs: tc.prs, bot: tc.bot}
var open *pullRequest
for i := range tc.prs {
if tc.prs[i].Number == tc.open {
open = &tc.prs[i]
}
}
src, err := findLineRecord(gh, l, open)
switch {
case tc.refusal != "":
if err == nil || !strings.Contains(err.Error(), tc.refusal) {
t.Errorf("findLineRecord = %+v, %v; want a refusal with %q", src, err, tc.refusal)
}
case err != nil:
t.Errorf("findLineRecord: %v", err)
case tc.carrier == 0 && (src.carrier != nil || src.rec != nil):
t.Errorf("found #%d, want no record", src.carrier.Number)
case tc.carrier != 0 && (src.carrier == nil || src.carrier.Number != tc.carrier || src.rec == nil || src.open != tc.isOpen || src.rec.Deferred[0].Original != tc.carrier):
t.Errorf("found %+v, want #%d open=%v", src, tc.carrier, tc.isOpen)
}
if tc.pages != 0 && gh.listed != tc.pages {
t.Errorf("read %d pages, want %d", gh.listed, tc.pages)
}
if tc.open != 0 && src.code != open {
t.Errorf("the batch to build on is %+v, want #%d", src.code, tc.open)
}
})
}
}
// A run that picks nothing while no batch is open writes nothing, since
// merged and closed batches are never written: it says its classification is
// not kept, and check still reads the merged batch's record.
func TestZeroPickRunLeavesNoRecord(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only five", map[string]string{"app/five.txt": "line\n", "app/seven.txt": "line\n"})
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
mergeBatch(f, gh)
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
edits, comments := len(gh.edited), len(gh.issueComments)
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push")
if code != 0 || !strings.Contains(stderr, "no batch pull request is open on release-1.1; this run's classification is not retained") {
t.Errorf("run = %d, want 0 saying nothing is retained:\n%s", code, stderr)
}
if len(gh.created) != 1 || len(gh.edited) != edits || len(gh.issueComments) != comments || f.remoteHead(batchBranch1) != "" {
t.Errorf("created %q, edited %q, %d comments, branch at %q; want nothing written", gh.created, gh.edited, len(gh.issueComments), f.remoteHead(batchBranch1))
}
f.git("switch", "-q", "main")
code, stdout, stderr := f.exec(gh, "check", "--line", "release-1.1")
if code != 1 || !strings.Contains(stdout, "/pull/7 ") || strings.Contains(stdout, "/pull/5 ") || !strings.Contains(stderr, "deferred conflict on #90 (merged)") {
t.Errorf("check = %d, want 1 for #7 alone and #5 still a deferred conflict:\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr)
}
}
// The open batch's author is asked for once although both the open batch and
// its record are read.
func TestRecordAsksForAPermissionOnce(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
gh.asked = nil
if code, _, stderr := f.exec(gh, "check", "--line", "release-1.1"); code != 0 {
t.Fatalf("check = %d\n%s", code, stderr)
}
if len(gh.asked) != 1 {
t.Errorf("asked GitHub for %q, want the author once", gh.asked)
}
}
// The record and the report name what the classification was made of: the
// heads it read, the policy, the tool, the job, the time and the flags.
func TestRecordNamesItsInputs(t *testing.T) {
f := newFixture(t)
f.lines()
mc5 := f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.onLine("release-1.1", "fix(app): five\n\n(cherry picked from commit "+mc5+")", map[string]string{"app/five.txt": "5\n"})
f.tool = strings.Repeat("c", 40) + "+dirty"
t.Setenv("GITHUB_SERVER_URL", "https://github.com")
t.Setenv("GITHUB_REPOSITORY", "cozystack/cozystack")
t.Setenv("GITHUB_RUN_ID", "123")
t.Setenv("GITHUB_RUN_ATTEMPT", "2")
gh := &fakeGH{}
stdout := mustRun(t, f, gh, "run", "--line", "release-1.0", "--no-workflow-files", "--push")
l, _ := parseLine("release-1.0")
const branch = "backport-batch/release-1.0-20261026"
rec, err := parseRecord(stdout, l)
if err != nil {
t.Fatal(err)
}
want := builtFrom{Main: f.git("rev-parse", "origin/main"), Line: f.git("rev-parse", "origin/release-1.0"), Newer: f.git("rev-parse", "origin/release-1.1"),
Policy: f.git("rev-parse", "origin/main"), Batch: f.remoteHead(branch), Tool: f.tool, Run: "https://github.com/cozystack/cozystack/actions/runs/123/attempts/2",
At: "2026-10-26T06:00:00Z", Options: runOptions{NoWorkflowFiles: true, Mode: modeNew}}
if rec.Version != 2 || *rec.Built != want || bodyReportOf(t, stdout, l).doc.Built != want || rec.Report != bodyReportOf(t, stdout, l).doc.ID {
t.Errorf("record = %+v, built %+v; want built %+v and the report it names", rec, rec.Built, want)
}
// A policy from another ref, a run by hand that continues the batch.
f.git("push", "-q", "origin", "origin/main:refs/heads/policy-draft")
f.git("fetch", "-q", "origin")
t.Setenv("GITHUB_RUN_ID", "")
gh.prs = []pullRequest{f.batchPR(90, "release-1.0", branch, stdout)}
mc6 := f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
f.onLine("release-1.1", "fix(app): six\n\n(cherry picked from commit "+mc6+")", map[string]string{"app/six.txt": "6\n"})
stdout = mustRun(t, f, gh, "run", "--line", "release-1.0", "--continue", "--policy", "origin/policy-draft", "--push")
if rec, err = parseRecord(stdout, l); err != nil {
t.Fatal(err)
}
if b := rec.Built; b.Policy != f.git("rev-parse", "origin/policy-draft") || b.Main != f.git("rev-parse", "origin/main") || b.Run != "" || b.Options != (runOptions{Mode: modeContinue}) || b.Batch != f.remoteHead(branch) {
t.Errorf("built %+v; want the draft's policy, no job and a continuation without the flag", b)
}
}
// What a run assembled with --no-workflow-files stays saved through the
// operations that only change the record, which take no such flag: check
// keeps failing the cut on the wait they leave in place.
func TestMetadataOperationsKeepTheMode(t *testing.T) {
for _, v1 := range []bool{false, true} {
t.Run(fmt.Sprintf("an open record of version 1: %v", v1), func(t *testing.T) {
f := workflowWorld(t)
f.onLine("release-1.1", "fix(app): a line-only nine", map[string]string{"app/nine.txt": "line\n"})
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
f.pr(9, "fix(app): nine", "", map[string]string{"app/nine.txt": "9\n"})
gh := &fakeGH{}
stdout := mustRun(t, f, gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push")
reopen(f, gh)
if v1 {
waits := []pick{{v: &verdict{o: &original{number: 5}}, files: []string{".github/workflows/five.yaml"}}}
gh.prs[0].Body = strings.Replace(stdout, strings.Split(strings.Split(stdout, deferredFence+"\n")[1], "\n")[0], strings.Split(deferredBlock(notifyLine, nil, nil, waits), "\n")[1], 1)
} else if rec, err := parseRecord(stdout, notifyLine); err != nil || !rec.Built.Options.NoWorkflowFiles {
t.Fatalf("the scheduled run saved %+v, %v", rec, err)
}
for _, op := range []struct {
args []string
mode string
}{
{[]string{"--drop", "9", "--reason", "needs #4"}, modeDrop},
{[]string{"--undrop", "9"}, modeUndrop},
{[]string{"--defer", "7", "--reason", "fails the e2e"}, modeDefer},
} {
stdout = mustRun(t, f, gh, append(append([]string{"run", "--line", "release-1.1"}, op.args...), "--push")...)
rec, err := parseRecord(stdout, notifyLine)
if err != nil || rec.Built.Options != (runOptions{NoWorkflowFiles: true, Mode: op.mode}) {
t.Errorf("after %q the record is %+v, %v; want the flag kept", op.args, rec.Built, err)
}
reopen(f, gh)
}
var rec *lineRecord
var err error
reopen(f, gh)
if code, stdout, _ := f.exec(gh, "check", "--line", "release-1.1", "--cut"); code != 1 || !strings.Contains(stdout, "#5 changes .github/workflows/") {
t.Errorf("check --cut = %d, want #5 still waiting:\n%s", code, stdout)
}
// Deferring the last picks closes the batch, and its record
// still says how the batch was assembled.
mustRun(t, f, gh, "run", "--line", "release-1.1", "--defer", "6,8", "--reason", "fails the e2e", "--push")
rec, err = parseRecord(gh.find(90).Body, notifyLine)
if err != nil || gh.find(90).State != "closed" || rec.Closing == nil || rec.Built.Options != (runOptions{NoWorkflowFiles: true, Mode: modeClose}) {
t.Errorf("after the close the record is %+v, %v; want the flag kept", rec.Built, err)
}
if code, stdout, _ := f.exec(gh, "check", "--line", "release-1.1", "--cut"); code != 1 || !strings.Contains(stdout, "/pull/5 ") {
t.Errorf("check --cut = %d, want #5 missing once no batch lists it:\n%s", code, stdout)
}
})
}
}
// A state is named only when it is one GitHub uses; anything else is quoted.
func TestPRStateNamesOnlyKnownStates(t *testing.T) {
at := "2026-10-26T12:00:00Z"
for _, tc := range []struct {
pr pullRequest
want string
}{
{pullRequest{State: "open"}, "open"},
{pullRequest{State: ""}, "open"},
{pullRequest{State: "closed"}, "closed"},
{pullRequest{State: "closed", MergedAt: &at}, "merged"},
{pullRequest{State: "open\n\x1b[2Jforged"}, `in state "open\n\x1b[2Jforged"`},
} {
if got := prState(&tc.pr); got != tc.want {
t.Errorf("prState(%q) = %q, want %q", tc.pr.State, got, tc.want)
}
}
}
+752
View File
@@ -0,0 +1,752 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"runtime/debug"
"slices"
"strconv"
"strings"
"time"
)
// A report is one classification of a line: where every original merged into
// main since the line branched stands, and what it was made of. The batch
// pull request's body carries the latest one beside the record, and an issue
// comment the tool never edits keeps each one, so a later run that replaces
// the body loses none of them. Its ID hashes the classification, not the pull
// request it lands on, which a new batch does not have yet when the report is
// made.
const (
reportFence = "```backport-batch-report"
reportMarker = "<!-- backport-batch:report "
reportFormat = 1
// reportLimit keeps a report comment well inside GitHub's 65536.
reportLimit = 60000
)
type reportDoc struct {
Format int `json:"format"`
ID string `json:"id"`
Line string `json:"line"`
Built builtFrom `json:"built"`
Overrides reportOverrides `json:"overrides"`
Primary reportPrimary `json:"primary"`
Overlays reportOverlays `json:"overlays"`
Diag map[string][]string `json:"diag"`
Other []string `json:"other"`
}
type reportOverrides struct {
Include []int `json:"backport/include"`
Skip []int `json:"backport/skip"`
}
// reportPrimary puts every original in exactly one place.
type reportPrimary struct {
Picked []int `json:"picked"`
Missing []int `json:"missing"`
Deferred []deferredEntry `json:"deferred"`
Parked []json.RawMessage `json:"parked"`
Skipped reportSkipped `json:"skipped"`
}
type reportSkipped struct {
OnLine []int `json:"on-line"`
BeforeStart []int `json:"before-start"`
Type []int `json:"type"`
Breaking []int `json:"breaking"`
Touches []int `json:"touches"`
Newer []int `json:"newer"`
SkipLabel []int `json:"skip-label"`
Dropped []int `json:"dropped"`
}
// reportOverlays mark originals that are also something else: stale ones
// are excluded but still on the batch, and each drop is the record's entry,
// with the merge commit it is bound to and its reason.
type reportOverlays struct {
Dropped []droppedEntry `json:"dropped"`
Stale []int `json:"stale"`
Changed []int `json:"changed"`
}
func (s *reportSkipped) list(rule int) *[]int {
switch rule {
case ruleOnLine:
return &s.OnLine
case ruleBeforeStart:
return &s.BeforeStart
case ruleType:
return &s.Type
case ruleBreaking:
return &s.Breaking
case ruleTouches:
return &s.Touches
case ruleNewer:
return &s.Newer
case ruleSkipLabel:
return &s.SkipLabel
case ruleDropped:
return &s.Dropped
}
return nil
}
type namedList struct {
name string
list *[]int
}
// lists names each skipped list as the report does.
func (s *reportSkipped) lists() []namedList {
return []namedList{
{"on-line", &s.OnLine}, {"before-start", &s.BeforeStart}, {"type", &s.Type}, {"breaking", &s.Breaking},
{"touches", &s.Touches}, {"newer", &s.Newer}, {"skip-label", &s.SkipLabel}, {"dropped", &s.Dropped},
}
}
func numbers(vs []*verdict) []int {
out := []int{}
for _, v := range vs {
out = append(out, v.o.number)
}
return out
}
// deferredEntries is what the record and the report say about the conflicts,
// the deferrals and the waits of a run.
func deferredEntries(conflicts, held, waiting []pick) []deferredEntry {
out := []deferredEntry{}
for _, p := range conflicts {
files := cleanFiles(p.files[:min(len(p.files), filesKept)])
out = append(out, deferredEntry{Original: p.v.o.number, Commit: p.failed, Files: files, More: len(p.files) - len(files) + p.more})
}
for _, p := range held {
out = append(out, deferredEntry{Original: p.v.o.number, Files: []string{}, Reason: capped(oneLine(clean(p.reason, false)), titleLimit)})
}
for _, p := range waiting {
files := cleanFiles(p.files[:min(len(p.files), filesKept)])
out = append(out, deferredEntry{Original: p.v.o.number, Files: files, More: len(p.files) - len(files) + p.more, Workflows: true})
}
return out
}
// buildReport makes the report of a run whose built is set. Every original
// main merged since the line branched has to land in exactly one primary
// category; one that does not is an error rather than a report that
// silently drops it.
func buildReport(b *batch, r *report) (reportDoc, error) {
doc := reportDoc{Format: reportFormat, Line: b.line.branch(), Built: r.built, Diag: map[string][]string{}, Other: append([]string{}, b.other...)}
doc.Overrides = reportOverrides{Include: []int{}, Skip: []int{}}
for _, v := range r.overrides {
switch v.label {
case labelInclude:
doc.Overrides.Include = append(doc.Overrides.Include, v.o.number)
case labelSkip:
doc.Overrides.Skip = append(doc.Overrides.Skip, v.o.number)
}
}
doc.Primary = reportPrimary{Picked: numbers(r.onBranch), Missing: numbers(r.missing), Deferred: deferredEntries(r.deferred, r.held, r.waiting), Parked: []json.RawMessage{}}
for _, l := range doc.Primary.Skipped.lists() {
*l.list = []int{}
}
for rule, vs := range r.skipped {
list := doc.Primary.Skipped.list(rule)
if list == nil {
return doc, fmt.Errorf("the report has no place for what rule %d skipped", rule)
}
*list = append(*list, numbers(vs)...)
}
doc.Overlays = reportOverlays{Dropped: []droppedEntry{}, Stale: numbers(r.stale), Changed: []int{}}
doc.Overlays.Dropped = append(doc.Overlays.Dropped, r.dropped...)
doc.canonical()
where, err := doc.categories()
if err != nil {
return doc, err
}
for _, v := range b.verdicts {
if where[v.o.number] == "" {
return doc, fmt.Errorf("the report of %s puts #%d nowhere", b.line.branch(), v.o.number)
}
delete(where, v.o.number)
}
for n := range where {
return doc, fmt.Errorf("the report of %s names #%d, which main did not merge after the line branched", b.line.branch(), n)
}
doc.ID = reportID(doc)
return doc, nil
}
// canonical sorts every list, so equal classifications are equal bytes.
func (d *reportDoc) canonical() {
lists := []*[]int{&d.Primary.Picked, &d.Primary.Missing, &d.Overrides.Include, &d.Overrides.Skip, &d.Overlays.Stale, &d.Overlays.Changed}
for _, l := range d.Primary.Skipped.lists() {
lists = append(lists, l.list)
}
for _, l := range lists {
slices.Sort(*l)
}
slices.SortFunc(d.Primary.Deferred, func(a, b deferredEntry) int { return cmp.Compare(a.Original, b.Original) })
slices.SortFunc(d.Overlays.Dropped, func(a, b droppedEntry) int { return cmp.Compare(a.Original, b.Original) })
slices.Sort(d.Other)
}
// categories maps each original the report names to its primary category,
// refusing one named twice.
func (d *reportDoc) categories() (map[int]string, error) {
where := map[int]string{}
put := func(n int, category string) error {
if n <= 0 {
return fmt.Errorf("the report puts original %d in %s", n, category)
}
if prev := where[n]; prev != "" {
return fmt.Errorf("the report puts #%d in both %s and %s", n, prev, category)
}
where[n] = category
return nil
}
for _, n := range d.Primary.Picked {
if err := put(n, "picked"); err != nil {
return nil, err
}
}
for _, n := range d.Primary.Missing {
if err := put(n, "missing"); err != nil {
return nil, err
}
}
for _, e := range d.Primary.Deferred {
if err := put(e.Original, "deferred"); err != nil {
return nil, err
}
}
for _, l := range d.Primary.Skipped.lists() {
for _, n := range *l.list {
if err := put(n, "skipped."+l.name); err != nil {
return nil, err
}
}
}
return where, nil
}
// reportID hashes everything a report says but when and in which job it was
// made, so a run that finds what the last one found makes the same report.
func reportID(doc reportDoc) string {
doc.ID, doc.Built.At, doc.Built.Run = "", "", ""
data, _ := json.Marshal(doc)
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// storedReport is a report as it was written, whose bytes a replay posts
// unchanged.
type storedReport struct {
doc reportDoc
raw string
}
func reportJSON(doc reportDoc) string {
data, _ := json.Marshal(doc)
return string(data)
}
func reportBlock(raw string) string { return reportFence + "\n" + raw + "\n```" }
// parseReport reads one report line of a batch pull request against l. An
// ID that matches the content shows only that nobody changed it since it
// was hashed, so everything it says is also held to what a run writes.
func parseReport(raw string, l line) (reportDoc, error) {
var doc reportDoc
if err := decodeStrict(raw, &doc); err != nil {
return doc, fmt.Errorf("reading the report: %w", err)
}
if doc.Format != reportFormat {
return doc, fmt.Errorf("the report is format %d, which this backport-batch does not read", doc.Format)
}
if !reportIDRE.MatchString(doc.ID) || reportID(doc) != doc.ID {
return doc, fmt.Errorf("report %.12s does not match what it says", terminalSafe(doc.ID))
}
if err := doc.validate(l); err != nil {
return doc, fmt.Errorf("report %.12s %w", doc.ID, err)
}
if err := doc.bindDrops(); err != nil {
return doc, fmt.Errorf("report %.12s %w", doc.ID, err)
}
return doc, nil
}
// validate holds a report to what a run writes. Its errors read after
// "report <id>".
func (d *reportDoc) validate(l line) error {
if d.Line != l.branch() {
return fmt.Errorf("is for %q, not %s", d.Line, l.branch())
}
if err := d.Built.validate(); err != nil {
return err
}
for _, sha := range d.Other {
if checkSHA(sha) != nil {
return fmt.Errorf("counts %q as a commit on main", sha)
}
}
if _, err := d.categories(); err != nil {
return err
}
// The deferred and dropped entries are the record's, held to the
// record's rules.
kept := lineRecord{Version: 1, Line: d.Line, Deferred: d.Primary.Deferred, Dropped: d.Overlays.Dropped}
if err := kept.validate(l); err != nil {
return err
}
for n, lines := range d.Diag {
if k, err := strconv.Atoi(n); err != nil || k <= 0 || strconv.Itoa(k) != n {
return fmt.Errorf("has diagnostics for %q, which is no original", n)
}
for _, ln := range lines {
if ln != oneLineText(ln) {
return fmt.Errorf("has a diagnostic for #%s that is not one clean line: %q", n, ln)
}
}
}
for _, list := range [][]int{d.Overrides.Include, d.Overrides.Skip, d.Overlays.Stale, d.Overlays.Changed} {
for _, n := range list {
if n <= 0 {
return fmt.Errorf("names original %d", n)
}
}
}
return d.links()
}
// links holds the fields of a report that speak of one another to agree as
// a run writes them, so no field says something another contradicts. Its
// errors read after "report <id>".
func (d *reportDoc) links() error {
where, err := d.categories()
if err != nil {
return err
}
// PR B gives parked originals a shape; until then none is written.
if len(d.Primary.Parked) > 0 {
return errors.New("parks originals, which this backport-batch does not do")
}
drops := map[int]bool{}
for _, e := range d.Overlays.Dropped {
drops[e.Original] = true
}
for _, n := range d.Primary.Skipped.Dropped {
if !drops[n] {
return fmt.Errorf("has #%d dropped, but keeps no drop of it", n)
}
}
// Stale and changed mark something a run took or excluded.
for _, o := range []struct {
name string
list []int
}{{"stale", d.Overlays.Stale}, {"changed", d.Overlays.Changed}} {
for _, n := range o.list {
if c := where[n]; c != "picked" && !strings.HasPrefix(c, "skipped.") {
return fmt.Errorf("marks #%d %s, which it has %s", n, o.name, or(c, "nowhere"))
}
}
}
// backport/skip decides before any rule, and backport/include lifts
// rules 1 to 4.
skip := map[int]bool{}
for _, n := range d.Overrides.Skip {
skip[n] = true
if where[n] != "skipped.skip-label" {
return fmt.Errorf("has #%d labelled %s, which it has %s", n, labelSkip, or(where[n], "nowhere"))
}
}
for _, n := range d.Primary.Skipped.SkipLabel {
if !skip[n] {
return fmt.Errorf("has #%d skipped for a %s label it does not list", n, labelSkip)
}
}
for _, n := range d.Overrides.Include {
switch c := where[n]; c {
case "", "skipped.before-start", "skipped.type", "skipped.breaking", "skipped.touches", "skipped.skip-label":
return fmt.Errorf("has #%d labelled %s, which it has %s", n, labelInclude, or(c, "nowhere"))
}
}
for n := range d.Diag {
if k, err := strconv.Atoi(n); err != nil || where[k] == "" {
return fmt.Errorf("has diagnostics for %q, which it puts nowhere", n)
}
}
for _, e := range d.Primary.Deferred {
if e.Workflows && !d.Built.Options.NoWorkflowFiles {
return fmt.Errorf("has #%d waiting for its workflow files, but was made bringing them over", e.Original)
}
}
if d.Built.Options.Mode == modeClose && len(d.Primary.Picked)+len(d.Overlays.Stale) > 0 {
return errors.New("closes the batch, but still has originals on it")
}
return nil
}
// bodyReport is the report a batch body against l carries, or nil.
func bodyReport(body string, l line) (*storedReport, error) {
blocks, err := fencedLines(body, reportFence)
switch {
case err != nil:
return nil, err
case len(blocks) == 0:
return nil, nil
case len(blocks) > 1:
return nil, fmt.Errorf("the body holds %d %s blocks", len(blocks), reportFence)
}
doc, err := parseReport(blocks[0], l)
if err != nil {
return nil, err
}
return &storedReport{doc: doc, raw: blocks[0]}, nil
}
// reportComment is the comment that keeps a report: a marker naming it, and
// the report folded away in the same block the body carries. Nothing in it
// mentions anyone: numbers are bare, and text sits inside the fence.
//
// Only checked values reach the summary GitHub renders: an ID of hex digits,
// a release line and the date of a time that parses.
func reportComment(s storedReport) (string, error) {
if !reportIDRE.MatchString(s.doc.ID) {
return "", fmt.Errorf("report %q has no ID", s.doc.ID)
}
if _, err := parseLine(s.doc.Line); err != nil {
return "", fmt.Errorf("report %.12s: %w", s.doc.ID, err)
}
at, err := time.Parse(time.RFC3339, s.doc.Built.At)
if err != nil {
return "", fmt.Errorf("report %.12s was made at %q", s.doc.ID, s.doc.Built.At)
}
date := at.UTC().Format(time.DateOnly)
c := reportMarker + s.doc.ID + " -->\n" +
"<details><summary>Backport batch report for " + s.doc.Line + ", " + date + " (" + s.doc.ID[:12] + ")</summary>\n\n" +
reportBlock(s.raw) + "\n</details>\n"
if len(c) > reportLimit {
return "", fmt.Errorf("the report comment is %d bytes, over the %d it may have", len(c), reportLimit)
}
return c, nil
}
var reportMarkerRE = regexp.MustCompile(`^` + regexp.QuoteMeta(reportMarker) + `([0-9a-f]{64}) -->\n`)
// findReports reads the report comments on batch pull request n, against l,
// by authors the batch trusts, as it trusts a record's author, by ID. One
// whose content does not match the ID its marker gives, or is not what a run
// writes, is returned apart, with why; a look-alike by anyone else is not
// read, whatever it says.
func findReports(gh github, n int, l line) (map[string]storedReport, map[string]string, error) {
comments, err := gh.comments(n)
if err != nil {
return nil, nil, err
}
found, bad := map[string]storedReport{}, map[string]string{}
for _, c := range comments {
if !strings.HasPrefix(c.Body, reportMarker) {
continue
}
why, err := trustedAuthor(gh, c.User.Login, c.User.Type)
if err != nil {
return nil, nil, err
}
if why != "" {
continue
}
m := reportMarkerRE.FindStringSubmatch(c.Body)
if m == nil {
bad[strconv.FormatInt(c.ID, 10)] = fmt.Sprintf("comment %d starts like a report but names none", c.ID)
continue
}
blocks, err := fencedLines(c.Body, reportFence)
if err == nil && len(blocks) != 1 {
err = fmt.Errorf("it holds %d reports", len(blocks))
}
var doc reportDoc
if err == nil {
doc, err = parseReport(blocks[0], l)
}
if err == nil && doc.ID != m[1] {
err = fmt.Errorf("its marker names %.12s", m[1])
}
if err != nil {
bad[m[1]] = fmt.Sprintf("comment %d: %v", c.ID, err)
continue
}
found[m[1]] = storedReport{doc: doc, raw: blocks[0]}
}
return found, bad, nil
}
// toolVersion is the commit this binary was built from, with +dirty when its
// tree had changes, or "" when the build did not record it, as go run does
// not.
func toolVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
rev, dirty := "", false
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if checkSHA(rev) != nil {
return ""
}
if dirty {
rev += "+dirty"
}
return rev
}
// runURL is the Actions run the command runs in, from the variables GitHub
// sets for it, or "" outside one.
func runURL(lookup func(string) (string, bool)) string {
get := func(key string) string { v, _ := lookup(key); return v }
url := get("GITHUB_SERVER_URL") + "/" + get("GITHUB_REPOSITORY") + "/actions/runs/" + get("GITHUB_RUN_ID")
if attempt := get("GITHUB_RUN_ATTEMPT"); attempt != "" && attempt != "1" {
url += "/attempts/" + attempt
}
if !runURLRE.MatchString(url) {
return ""
}
return url
}
// report prints a report a batch pull request keeps, writing nothing, so it
// works on merged and closed batches too. It reads GitHub, and for a report
// that keeps drops also this clone's history at the heads the report was
// made from, which it never fetches.
func (a *app) report(args []string) int {
fs := a.flags("report")
number := fs.Int("pr", 0, "the batch pull request")
id := fs.String("id", "", "the report, by its ID (default: the one the body carries)")
asJSON := fs.Bool("json", false, "print the report's JSON")
if err := parse(fs, args); err != nil {
return 2
}
if *number <= 0 {
return a.fail(fmt.Errorf("--pr %d is not a pull request number", *number))
}
if *id != "" && !reportIDRE.MatchString(*id) {
return a.fail(fmt.Errorf("--id %q is not a report ID", *id))
}
pr, err := a.gh.pull(*number)
if err != nil {
return a.fail(err)
}
l, err := parseLine(pr.Base.Ref)
if m := batchBranchRE.FindStringSubmatch(pr.Head.Ref); err != nil || m == nil || m[1] != l.branch() || pr.Head.Repo == nil || pr.Head.Repo.FullName != pr.Base.Repo.FullName {
return a.fail(fmt.Errorf("#%d is not a batch pull request of this repository", pr.Number))
}
why, err := trusted(a.gh, pr)
if err != nil {
return a.fail(err)
}
if why != "" {
return a.fail(fmt.Errorf("batch pull request #%d was %s, so what it says is not the batch's", pr.Number, why))
}
inBody, err := bodyReport(pr.Body, l)
if err != nil {
return a.fail(fmt.Errorf("batch pull request #%d: %w", pr.Number, err))
}
if *id == "" {
if inBody == nil {
return a.fail(fmt.Errorf("batch pull request #%d carries no report in its body; name one with --id", pr.Number))
}
*id = inBody.doc.ID
}
posted, bad, err := findReports(a.gh, pr.Number, l)
if err != nil {
return a.fail(err)
}
if why := bad[*id]; why != "" {
return a.fail(fmt.Errorf("batch pull request #%d: the report %.12s is not what its comment says: %s", pr.Number, *id, why))
}
s, inComment := posted[*id]
switch {
case inComment:
case inBody != nil && inBody.doc.ID == *id:
s = *inBody
default:
return a.fail(fmt.Errorf("batch pull request #%d keeps no report %.12s", pr.Number, *id))
}
if *asJSON {
fmt.Fprintln(a.stdout, s.raw)
return 0
}
a.printReport(&pr, s, inBody, inComment)
return 0
}
// printReport prints a report. It was checked when it was read, and every
// field still goes through terminalSafe, so nothing it carries spans lines
// or drives a terminal.
func (a *app) printReport(pr *pullRequest, s storedReport, inBody *storedReport, inComment bool) {
d, b := s.doc, s.doc.Built
safe := terminalSafe
fmt.Fprintf(a.stdout, "report %s for %s, made %s by a %s run", safe(d.ID), safe(d.Line), safe(b.At), safe(b.Options.Mode))
if b.Options.NoWorkflowFiles {
fmt.Fprint(a.stdout, " with --no-workflow-files")
}
fmt.Fprintln(a.stdout)
fmt.Fprintf(a.stdout, " main %s, line %s, newer line %s, policy %s\n batch %s, tool %s, run %s\n", safe(b.Main), safe(b.Line), safe(or(b.Newer, "none")), safe(b.Policy), safe(b.Batch), safe(or(b.Tool, "unknown")), safe(or(b.Run, "none")))
current := "none"
if inBody != nil {
current = safe(inBody.doc.ID)
}
switch {
case inBody != nil && inBody.doc.ID == d.ID:
fmt.Fprintf(a.stdout, " #%d's body carries this report\n", pr.Number)
case pr.Head.SHA == b.Batch:
fmt.Fprintf(a.stdout, " #%d's head is %.12s, the batch this report describes, but its body carries report %.12s\n", pr.Number, safe(b.Batch), current)
default:
fmt.Fprintf(a.stdout, " publication unconfirmed: #%d's head is %.12s, not %.12s, and its body carries report %.12s\n", pr.Number, safe(pr.Head.SHA), safe(b.Batch), current)
}
if !inComment {
if prState(pr) == "open" {
fmt.Fprintln(a.stdout, " comment not posted; the next backport-batch run --push posts it")
} else {
fmt.Fprintf(a.stdout, " comment not posted; kept in the body only, since nothing is written to a %s pull request\n", prState(pr))
}
}
type line struct {
n int
text string
}
var lines []line
for _, n := range d.Primary.Picked {
lines = append(lines, line{n, "picked"})
}
for _, n := range d.Primary.Missing {
lines = append(lines, line{n, "missing"})
}
for _, e := range d.Primary.Deferred {
text := "deferred: "
switch {
case e.Workflows:
text += "waits for a maintainer, changes " + strings.Join(e.Files, ", ")
case e.Reason != "":
text += e.Reason
default:
text += fmt.Sprintf("conflict at %.12s in %s", e.Commit, strings.Join(e.Files, ", "))
}
if e.More > 0 {
text += fmt.Sprintf(" and %d more", e.More)
}
lines = append(lines, line{e.Original, text})
}
for _, l := range d.Primary.Skipped.lists() {
for _, n := range *l.list {
lines = append(lines, line{n, "skipped." + l.name})
}
}
slices.SortFunc(lines, func(x, y line) int { return cmp.Compare(x.n, y.n) })
for _, l := range lines {
fmt.Fprintf(a.stdout, "#%d %s\n", l.n, terminalSafe(l.text))
}
overlay := func(name string, list []int) {
if len(list) > 0 {
fmt.Fprintf(a.stdout, "%s: %s\n", name, strings.Trim(fmt.Sprint(list), "[]"))
}
}
overlay("stale, still on the batch", d.Overlays.Stale)
for _, e := range d.Overlays.Dropped {
fmt.Fprintf(a.stdout, "dropped: #%d, merged as %.12s, on #%d: %s\n", e.Original, terminalSafe(e.Merge), e.On, terminalSafe(e.Reason))
}
overlay("changed in the pick", d.Overlays.Changed)
if len(d.Other) > 0 {
other := make([]string, len(d.Other))
for i, sha := range d.Other {
other[i] = safe(sha)
}
fmt.Fprintf(a.stdout, "commits on main that merged no pull request: %s\n", strings.Join(other, ", "))
}
}
func or(s, otherwise string) string {
if s == "" {
return otherwise
}
return s
}
// postReport writes a report comment on pull request n, after GitHub says
// it is still open: nothing is written to a merged or closed one.
func (a *app) postReport(n int, s storedReport) error {
if err := a.stillOpen(n); err != nil {
return err
}
c, err := reportComment(s)
if err != nil {
return err
}
name, err := writeTemp("backport-batch-report-*.md", c)
if err != nil {
return err
}
defer os.Remove(name)
return a.gh.createComment(n, name)
}
// stillOpen reads pull request n again and refuses to go on unless it is
// open.
func (a *app) stillOpen(n int) error {
pr, err := a.gh.pull(n)
if err != nil {
return err
}
if st := prState(&pr); st != "open" {
return fmt.Errorf("batch pull request #%d is %s now; nothing is written to it", n, st)
}
return nil
}
func writeTemp(pattern, content string) (string, error) {
f, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
+706
View File
@@ -0,0 +1,706 @@
/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode"
)
// reportsPosted lists the report IDs the comments carry, in the order they
// were posted, whoever wrote them.
func reportsPosted(gh *fakeGH) []string {
var ids []string
for _, c := range gh.issueComments {
if m := reportMarkerRE.FindStringSubmatch(c.Body); m != nil {
ids = append(ids, m[1])
}
}
return ids
}
// bodyReportOf is the report a body on release-1.1, or on the line given,
// carries, failing the test without one.
func bodyReportOf(t *testing.T, body string, on ...line) storedReport {
t.Helper()
l := notifyLine
if len(on) > 0 {
l = on[0]
}
s, err := bodyReport(body, l)
if err != nil || s == nil {
t.Fatalf("the body carries no readable report: %v\n%s", err, body)
}
return *s
}
// refusePushes makes the remote refuse every push, as a lost connection or
// a protected branch would, until it is called with false.
func (f *fixture) refusePushes(on bool) {
f.t.Helper()
hook := filepath.Join(f.remote, "hooks", "pre-receive")
if !on {
if err := os.Remove(hook); err != nil {
f.t.Fatal(err)
}
return
}
if err := os.WriteFile(hook, []byte("#!/bin/sh\necho refused >&2\nexit 1\n"), 0o755); err != nil {
f.t.Fatal(err)
}
}
func mustRun(t *testing.T, f *fixture, gh *fakeGH, args ...string) string {
t.Helper()
code, stdout, stderr := f.exec(gh, args...)
if code != 0 {
t.Fatalf("%q = %d\n%s", args, code, stderr)
}
f.git("switch", "-q", "main")
return stdout
}
// A report comment keeps each classification: one the next run replaced in
// the body is still read back by its ID, also once the batch merged, which
// nothing writes to.
func TestReportSurvivesContinuation(t *testing.T) {
f := newFixture(t)
f.lines()
f.onLine("release-1.1", "fix(app): a line-only five", map[string]string{"app/five.txt": "line\n"})
mc5 := f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
r1 := bodyReportOf(t, gh.prs[0].Body).doc.ID
f.onLine("release-1.1", "fix(app): five, by hand\n\n(cherry picked from commit "+mc5+")", map[string]string{"app/five.txt": "5\n"})
f.now = f.now.AddDate(0, 0, 1)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
r2 := bodyReportOf(t, gh.find(90).Body).doc.ID
if r1 == r2 || !slices.Equal(reportsPosted(gh), []string{r1, r2}) {
t.Fatalf("reports %q; want R1 %.12s, then R2 %.12s", reportsPosted(gh), r1, r2)
}
read := func(id, want string) {
t.Helper()
code, stdout, stderr := f.exec(gh, "report", "--pr", "90", "--id", id)
if code != 0 || !strings.Contains(stdout, want) {
t.Errorf("report --id %.12s = %d, want %q:\n%s%s", id, code, want, stdout, stderr)
}
}
for _, merged := range []bool{false, true} {
if merged {
mergeBatch(f, gh)
}
read(r1, "#5 deferred: conflict at ")
read(r2, "#5 skipped.on-line")
read(r2, "#6 picked")
}
}
// Two runs that find the same post one report, and the body carries the one
// kept, whatever time and job the second ran in. A look-alike someone else
// wrote is no report, and a kept report edited since stops the reader.
func TestReportDedupAndTrust(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
t.Setenv("GITHUB_SERVER_URL", "https://github.com")
t.Setenv("GITHUB_REPOSITORY", "cozystack/cozystack")
t.Setenv("GITHUB_RUN_ID", "100")
// The report the next run makes, read off a run that publishes nothing,
// planted by someone the batch does not trust.
preview := bodyReportOf(t, mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue"))
planted, err := reportComment(preview)
if err != nil {
t.Fatal(err)
}
look := issueComment{ID: 7, Body: planted}
look.User.Login, look.User.Type = "someone", "User"
gh.issueComments = append(gh.issueComments, look)
f.now = f.now.AddDate(0, 0, 1)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
kept := bodyReportOf(t, gh.find(90).Body)
if kept.doc.ID != preview.doc.ID {
t.Fatalf("the run made %.12s, the preview %.12s", kept.doc.ID, preview.doc.ID)
}
f.now = f.now.AddDate(0, 0, 1)
t.Setenv("GITHUB_RUN_ID", "101")
mustRun(t, f, gh, "run", "--line", "release-1.1", "--continue", "--push")
posted := reportsPosted(gh)
if n := len(slices.DeleteFunc(slices.Clone(posted), func(id string) bool { return id != kept.doc.ID })); n != 2 || len(posted) != 3 {
t.Errorf("reports %q; want the first, the look-alike and the run's own once", posted)
}
body := gh.find(90).Body
again := bodyReportOf(t, body)
rec, err := parseRecord(body, notifyLine)
if err != nil || again.raw != kept.raw || rec.Report != again.doc.ID || !strings.Contains(again.raw, `"run":"https://github.com/cozystack/cozystack/actions/runs/100"`) {
t.Errorf("the second run's body carries %s with record report %v (%v); want the kept bytes of run 100", again.raw, rec, err)
}
// The reader takes the run's own comment, not the look-alike.
if code, stdout, stderr := f.exec(gh, "report", "--pr", "90", "--id", kept.doc.ID); code != 0 || !strings.Contains(stdout, "#6 picked") {
t.Errorf("report = %d:\n%s%s", code, stdout, stderr)
}
for i := range gh.issueComments {
c := &gh.issueComments[i]
if c.User.Login == "maintainer" && strings.Contains(c.Body, kept.doc.ID) {
c.Body = strings.Replace(c.Body, `"picked":[5,6]`, `"picked":[5]`, 1)
}
}
if code, stdout, stderr := f.exec(gh, "report", "--pr", "90", "--id", kept.doc.ID); code != 2 || !strings.Contains(stderr, "does not match") {
t.Errorf("report of an edited comment = %d, want 2:\n%s%s", code, stdout, stderr)
}
}
// The report holds no pull request number, so a new batch's report is whole
// before GitHub numbers the pull request, and the reader finds it on
// whatever number it got.
func TestFirstReportIDIsKnownBeforeTheNumber(t *testing.T) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
gh := &fakeGH{nextPR: 4242}
mustRun(t, f, gh, "run", "--line", "release-1.1", "--push")
inBody := bodyReportOf(t, gh.bodies[0])
// Every list is one, empty or not, so equal reports are equal bytes:
// main here has no commit that merged no pull request.
if !strings.Contains(inBody.raw, `"other":[]`) {
t.Errorf("the report writes its empty list of other commits as something else:\n%s", inBody.raw)
}
if !slices.Equal(gh.commented, []int{4242}) || !slices.Equal(reportsPosted(gh), []string{inBody.doc.ID}) {
t.Fatalf("commented on %v with %q, want #4242 with %.12s", gh.commented, reportsPosted(gh), inBody.doc.ID)
}
found, bad, err := findReports(gh, 4242, notifyLine)
if err != nil || len(bad) != 0 || found[inBody.doc.ID].raw != inBody.raw {
t.Errorf("findReports = %v, %v, %v; want the body's report", found, bad, err)
}
if code, stdout, stderr := f.exec(gh, "report", "--pr", "4242"); code != 0 || !strings.Contains(stdout, "report "+inBody.doc.ID) || !strings.Contains(stdout, "#4242's body carries this report") {
t.Errorf("report --pr 4242 = %d:\n%s%s", code, stdout, stderr)
}
}
// Every original main merged since the line branched is in exactly one
// category of the report, a commit that merged no pull request is counted
// apart, and the report says so whatever the run had to do.
func TestReportPartition(t *testing.T) {
f := newFixture(t)
f.lines()
gh := &fakeGH{labels: map[string][]int{labelSkip: {9}}}
// onNewer puts an original on release-1.1, as the newer line's batch
// would, so rule 6 lets it onto release-1.0.
onNewer := func(mc string, files map[string]string) {
f.onLine("release-1.1", "fix: by the newer batch\n\n(cherry picked from commit "+mc+")", files)
}
pr := func(n int, title string, files map[string]string, newer bool) {
mc := f.pr(n, title, "", files)
if newer {
onNewer(mc, files)
}
}
t.Setenv("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z")
f.pr(4, "fix(app): four, before the start", "", map[string]string{"app/four.txt": "4\n"})
os.Unsetenv("GIT_COMMITTER_DATE")
f.setPolicy("release-1.0: 2026-06-01\n")
f.onLine("release-1.0", "fix(app): a line-only eleven", map[string]string{"app/eleven.txt": "line\n"})
pr(5, "fix(app): five", map[string]string{"app/five.txt": "5\n"}, true)
pr(6, "fix(app): six, not on the newer line", map[string]string{"app/six.txt": "6\n"}, false)
pr(7, "fix(app)!: seven", map[string]string{"app/seven.txt": "7\n"}, true)
pr(8, "fix(platform): eight", map[string]string{migrationsDir + "42": "#!/bin/sh\n"}, true)
pr(9, "fix(app): nine", map[string]string{"app/nine.txt": "9\n"}, true)
mc10 := f.pr(10, "fix(app): ten", "", map[string]string{"app/ten.txt": "10\n"})
onNewer(mc10, map[string]string{"app/ten.txt": "10\n"})
f.onLine("release-1.0", "fix(app): ten\n\n(cherry picked from commit "+mc10+")", map[string]string{"app/ten.txt": "10\n"})
pr(11, "fix(app): eleven", map[string]string{"app/eleven.txt": "11\n"}, true)
pr(12, "fix(ci): twelve", map[string]string{".github/workflows/twelve.yaml": "on: push\n"}, true)
pr(13, "fix(app): thirteen", map[string]string{"app/thirteen.txt": "13\n"}, true)
f.git("switch", "-q", "main")
f.write("app/direct.txt", "pushed\n")
f.git("add", "-A")
f.git("commit", "-q", "-m", "chore: a direct push")
direct := f.git("rev-parse", "HEAD")
f.git("push", "-q", "origin", "main")
stdout := mustRun(t, f, gh, "run", "--line", "release-1.0", "--no-workflow-files", "--push")
const branch = "backport-batch/release-1.0-20261026"
gh.prs = []pullRequest{f.batchPR(90, "release-1.0", branch, stdout)}
pr(14, "fix(app): fourteen, after the batch", map[string]string{"app/fourteen.txt": "14\n"}, true)
stdout = mustRun(t, f, gh, "run", "--line", "release-1.0", "--defer", "13", "--reason", "fails the e2e", "--push")
// An active drop, of #5, which the batch carries, and one of a feat,
// which rule 2 keeps out first.
gh.prs = []pullRequest{f.batchPR(90, "release-1.0", branch, stdout)}
stdout = mustRun(t, f, gh, "run", "--line", "release-1.0", "--drop", "5,1", "--reason", "not for 1.0", "--push")
l, _ := parseLine("release-1.0")
s := bodyReportOf(t, stdout, l)
got, err := s.doc.categories()
if err != nil {
t.Fatal(err)
}
want := map[int]string{1: "skipped.type", 4: "skipped.before-start", 5: "skipped.dropped", 6: "skipped.newer", 7: "skipped.breaking",
8: "skipped.touches", 9: "skipped.skip-label", 10: "skipped.on-line", 11: "deferred", 12: "deferred", 13: "deferred",
14: "missing", 9000: "skipped.type"}
for n, category := range want {
if got[n] != category {
t.Errorf("#%d is %q, want %q", n, got[n], category)
}
delete(got, n)
}
if len(got) != 0 {
t.Errorf("the report also names %v", got)
}
if gh.find(90).State != "closed" || !strings.Contains(stdout, `"mode":"close"`) {
t.Errorf("dropping the batch's last pick left #90 %s; want it closed", gh.find(90).State)
}
if !slices.Equal(droppedNumbers(s.doc.Overlays.Dropped), []int{1, 5}) || len(s.doc.Primary.Picked) != 0 {
t.Errorf("the report drops %v and picks %v; want #1 and #5 dropped, nothing picked", s.doc.Overlays.Dropped, s.doc.Primary.Picked)
}
if !slices.Contains(s.doc.Other, direct) {
t.Errorf("the report does not count the direct push %.12s: %v", direct, s.doc.Other)
}
kinds := map[int]string{}
for _, e := range s.doc.Primary.Deferred {
kinds[e.Original] = fmt.Sprintf("commit=%v reason=%q workflows=%v", e.Commit != "", e.Reason, e.Workflows)
}
if kinds[11] != `commit=true reason="" workflows=false` || kinds[12] != `commit=false reason="" workflows=true` || kinds[13] != `commit=false reason="fails the e2e" workflows=false` {
t.Errorf("the deferred entries are %v", kinds)
}
}
// A report too big for a comment is refused before anything is written: no
// push, no comment, no edit, and no replay of the earlier report either.
func TestReportTooLargeIsRefusedBeforePush(t *testing.T) {
f, gh, head := bigWaits(t, 7)
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--no-workflow-files", "--push")
if code != 2 || !strings.Contains(stderr, "the report comment is") {
t.Errorf("run --push = %d, want a refusal of the report:\n%s", code, stderr)
}
if f.remoteHead(batchBranch1) != head || len(gh.issueComments) != 0 || len(gh.edited) != 0 {
t.Errorf("the refused run moved the batch to %.12s, wrote %d comments and edited %q", f.remoteHead(batchBranch1), len(gh.issueComments), gh.edited)
}
}
// bigWaits opens a batch whose report comment failed, so its body keeps a
// report no comment does, and then merges n originals that each wait on 50
// workflow files with long names, which both the record and the report list
// in full.
func bigWaits(t *testing.T, n int) (*fixture, *fakeGH, string) {
t.Helper()
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
gh := &fakeGH{fail: map[string]error{"createComment": errString("HTTP 502")}}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--no-workflow-files", "--push"); code != 2 || len(gh.created) != 1 {
t.Fatalf("the first run = %d, want its comment to fail after the pull request opened\n%s", code, stderr)
}
f.git("switch", "-q", "main")
for i := range n {
files := map[string]string{}
for j := range filesKept {
files[fmt.Sprintf(".github/workflows/%s-%02d-%02d.yaml", strings.Repeat("w", 170), i, j)] = "on: push\n"
}
f.pr(10+i, fmt.Sprintf("fix(ci): wait %d", i), "", files)
}
f.now = f.now.AddDate(0, 0, 1)
return f, gh, f.remoteHead(batchBranch1)
}
// A failure between any two writes leaves what was written readable, and the
// next run, whatever changed meanwhile, replays a report the body kept but no
// comment did, byte for byte, and posts nothing twice. A batch closed in
// between is never written again.
func TestReportReplayAfterEachFailure(t *testing.T) {
setup := func(t *testing.T) (*fixture, *fakeGH) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
return f, &fakeGH{}
}
// change moves a label, main, the clock and the run's flags between
// the failure and the retry, so the retry's report is another one.
change := func(f *fixture, gh *fakeGH) []string {
gh.labels = map[string][]int{labelSkip: {6}}
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
f.now = f.now.AddDate(0, 0, 1)
return []string{"run", "--line", "release-1.1", "--continue", "--no-workflow-files", "--push"}
}
once := func(t *testing.T, gh *fakeGH, ids ...string) {
t.Helper()
posted := reportsPosted(gh)
for _, id := range ids {
if n := len(slices.DeleteFunc(slices.Clone(posted), func(p string) bool { return p != id })); n != 1 {
t.Errorf("report %.12s is posted %d times: %q", id, n, posted)
}
}
}
t.Run("a new batch whose pull request was not opened", func(t *testing.T) {
f, gh := setup(t)
gh.fail = map[string]error{"createPull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
if len(gh.prs) != 0 || len(gh.issueComments) != 0 || f.remoteHead(batchBranch1) == "" {
t.Errorf("after the failure: %d pull requests, %d comments, branch %q; want the branch alone", len(gh.prs), len(gh.issueComments), f.remoteHead(batchBranch1))
}
mustRun(t, f, gh, change(f, gh)...)
if len(gh.prs) != 1 || len(reportsPosted(gh)) != 1 || bodyReportOf(t, gh.prs[0].Body).doc.ID != reportsPosted(gh)[0] {
t.Errorf("the retry opened %d pull requests with reports %q", len(gh.prs), reportsPosted(gh))
}
})
t.Run("a new batch whose report comment failed", func(t *testing.T) {
f, gh := setup(t)
gh.fail = map[string]error{"createComment": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push"); code != 2 || !strings.Contains(stderr, "the body keeps it") {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
first := bodyReportOf(t, gh.find(90).Body)
mustRun(t, f, gh, change(f, gh)...)
second := bodyReportOf(t, gh.find(90).Body)
want, err := reportComment(first)
if err != nil || len(gh.issueComments) != 2 || gh.issueComments[0].Body != want || reportsPosted(gh)[1] != second.doc.ID || first.doc.ID == second.doc.ID {
t.Errorf("comments %q; want the first report replayed as it was, then the retry's", reportsPosted(gh))
}
})
t.Run("a continuation whose push failed", func(t *testing.T) {
f, gh := setup(t)
openBatch(t, f, gh)
first := reportsPosted(gh)[0]
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
f.refusePushes(true)
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
f.refusePushes(false)
attempted := reportsPosted(gh)[1]
if bodyReportOf(t, gh.find(90).Body).doc.ID != first {
t.Error("the body moved on although the push failed")
}
mustRun(t, f, gh, change(f, gh)...)
once(t, gh, first, attempted)
if len(reportsPosted(gh)) != 3 {
t.Errorf("reports %q; want the first, the attempted and the retry's", reportsPosted(gh))
}
})
t.Run("a continuation whose body edit failed", func(t *testing.T) {
f, gh := setup(t)
openBatch(t, f, gh)
first := reportsPosted(gh)[0]
before := f.remoteHead(batchBranch1)
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
gh.fail = map[string]error{"editPull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
attempted := reportsPosted(gh)[1]
if f.remoteHead(batchBranch1) == before || bodyReportOf(t, gh.find(90).Body).doc.ID != first {
t.Error("the push or the body is not where the failure left them")
}
mustRun(t, f, gh, change(f, gh)...)
once(t, gh, first, attempted)
if last := reportsPosted(gh)[len(reportsPosted(gh))-1]; bodyReportOf(t, gh.find(90).Body).doc.ID != last || len(reportsPosted(gh)) != 3 {
t.Errorf("reports %q; want the body to carry the retry's, posted last", reportsPosted(gh))
}
})
t.Run("a batch closed while the run writes to it", func(t *testing.T) {
f, gh := setup(t)
openBatch(t, f, gh)
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
gh.after = map[string]func(){"createComment": func() { gh.find(90).State = "closed" }}
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push")
if code != 2 || !strings.Contains(stderr, "batch pull request #90 is closed now; nothing is written to it") || len(gh.edited) != 0 {
t.Errorf("run = %d, edited %q; want 2 and no edit of the closed pull request:\n%s", code, gh.edited, stderr)
}
})
t.Run("a batch closed before its report was posted", func(t *testing.T) {
f, gh := setup(t)
gh.fail = map[string]error{"createComment": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
kept := bodyReportOf(t, gh.find(90).Body)
gh.find(90).State = "closed"
gh.nextPR = 91
mustRun(t, f, gh, change(f, gh)...)
if slices.Contains(gh.commented, 90) || slices.Contains(reportsPosted(gh), kept.doc.ID) {
t.Errorf("commented on %v, posting %q; want nothing on the closed #90", gh.commented, reportsPosted(gh))
}
code, stdout, stderr := f.exec(gh, "report", "--pr", "90")
if code != 0 || !strings.Contains(stdout, "report "+kept.doc.ID) || !strings.Contains(stdout, "kept in the body only") {
t.Errorf("report --pr 90 = %d:\n%s%s", code, stdout, stderr)
}
})
// Every comment is posted only after GitHub says the batch is still
// open, the replay's included, so a close between the two stops the
// second.
t.Run("a batch closed between the replay and the new report", func(t *testing.T) {
f, gh := setup(t)
gh.fail = map[string]error{"createComment": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
kept := bodyReportOf(t, gh.find(90).Body)
f.pr(8, "fix(app): eight", "", map[string]string{"app/eight.txt": "8\n"})
gh.after = map[string]func(){"createComment": func() { gh.find(90).State = "closed" }}
code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push")
if code != 2 || !strings.Contains(stderr, "batch pull request #90 is closed now; nothing is written to it") || !slices.Equal(reportsPosted(gh), []string{kept.doc.ID}) || len(gh.edited) != 0 {
t.Errorf("run = %d, posted %q, edited %q; want 2 after the replay alone:\n%s", code, reportsPosted(gh), gh.edited, stderr)
}
})
}
// The reader says how a report stands against the pull request as GitHub
// shows it now, and claims publication only when the head or the body shows
// it: a failed push, a push whose answer was lost, a body that was not
// updated and a rebuild since all read as they are.
func TestReportPublicationStatus(t *testing.T) {
setup := func(t *testing.T) (*fixture, *fakeGH, string) {
f := newFixture(t)
f.lines()
f.pr(5, "fix(app): five", "", map[string]string{"app/five.txt": "5\n"})
f.pr(6, "fix(app): six", "", map[string]string{"app/six.txt": "6\n"})
gh := &fakeGH{}
openBatch(t, f, gh)
return f, gh, reportsPosted(gh)[0]
}
status := func(t *testing.T, f *fixture, gh *fakeGH, id, want string) {
t.Helper()
code, stdout, stderr := f.exec(gh, "report", "--pr", "90", "--id", id)
if code != 0 || !strings.Contains(stdout, want) {
t.Errorf("report --id %.12s = %d, want %q:\n%s%s", id, code, want, stdout, stderr)
}
}
t.Run("a push the remote refused, and one it took whose answer was lost", func(t *testing.T) {
f, gh, first := setup(t)
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
f.refusePushes(true)
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.refusePushes(false)
attempted := reportsPosted(gh)[1]
status(t, f, gh, attempted, "publication unconfirmed: #90's head is ")
status(t, f, gh, attempted, "its body carries report "+first[:12])
// The same head reaching the remote is what a push GitHub took looks
// like when its answer never came back.
f.git("push", "-q", "origin", "HEAD:refs/heads/"+batchBranch1)
f.git("switch", "-q", "main")
status(t, f, gh, attempted, "the batch this report describes, but its body carries report "+first[:12])
})
t.Run("a push followed by a failed body edit", func(t *testing.T) {
f, gh, first := setup(t)
f.pr(7, "fix(app): seven", "", map[string]string{"app/seven.txt": "7\n"})
gh.fail = map[string]error{"editPull": errString("HTTP 502")}
if code, _, stderr := f.exec(gh, "run", "--line", "release-1.1", "--continue", "--push"); code != 2 {
t.Fatalf("run = %d\n%s", code, stderr)
}
f.git("switch", "-q", "main")
status(t, f, gh, reportsPosted(gh)[1], "the batch this report describes, but its body carries report "+first[:12])
status(t, f, gh, first, "#90's body carries this report")
})
t.Run("a rebuild since", func(t *testing.T) {
f, gh, first := setup(t)
mustRun(t, f, gh, "run", "--line", "release-1.1", "--defer", "6", "--reason", "x", "--push")
second := reportsPosted(gh)[1]
status(t, f, gh, first, "publication unconfirmed: #90's head is ")
status(t, f, gh, second, "#90's body carries this report")
})
}
// forged is a report on release-1.1 with what change sets, under an ID that
// matches it: only checking what it says stands in its way.
func forged(change func(d *reportDoc)) storedReport {
sha := strings.Repeat("a", 40)
d := reportDoc{Format: reportFormat, Line: "release-1.1", Diag: map[string][]string{}, Other: []string{sha},
Built: builtFrom{Main: sha, Line: sha, Policy: sha, Batch: sha, At: "2026-10-26T06:00:00Z", Options: runOptions{Mode: modeNew}}}
d.Primary.Picked = []int{5}
change(&d)
d.ID = reportID(d)
return storedReport{doc: d, raw: reportJSON(d)}
}
// reportRun runs report with gh faked; it reads nothing but GitHub.
func reportRun(gh *fakeGH, args ...string) (int, string, string) {
var stdout, stderr strings.Builder
a := &app{gh: gh, stdout: &stdout, stderr: &stderr, now: func() time.Time { return time.Date(2026, 10, 26, 6, 0, 0, 0, time.UTC) }}
code := a.main(append([]string{"report"}, args...))
return code, stdout.String(), stderr.String()
}
// A report is read only when everything it says is what a run writes, and
// it is for the line of the pull request it sits on: an ID that matches its
// content proves only that nobody changed it since it was hashed.
func TestReportRefusesWhatItCannotVouchFor(t *testing.T) {
for name, change := range map[string]func(d *reportDoc){
"a line that is no release line": func(d *reportDoc) { d.Line = "release-1.1\n\x1b[2Jpicked" },
"another line": func(d *reportDoc) { d.Line = "release-1.0" },
"a main that is no commit": func(d *reportDoc) { d.Built.Main = "main\n#5 picked" },
"a time that is none": func(d *reportDoc) { d.Built.At = "2026-10-26T<b>" },
"an unknown operation": func(d *reportDoc) { d.Built.Options.Mode = "new\x1b[2J" },
"a run that is no Actions run": func(d *reportDoc) { d.Built.Run = "javascript:alert(1)" },
"a tool that is no commit": func(d *reportDoc) { d.Built.Tool = "v1\n" },
"a commit on main that is none": func(d *reportDoc) { d.Other = []string{"HEAD\x1b]0;x\x07"} },
"a deferred commit that is none": func(d *reportDoc) {
d.Primary.Deferred = []deferredEntry{{Original: 6, Commit: "-x", Files: []string{"a"}}}
},
"an original named twice": func(d *reportDoc) { d.Primary.Missing = []int{5} },
"an original that is none": func(d *reportDoc) { d.Primary.Skipped.Type = []int{-1} },
"a diagnostic of two lines": func(d *reportDoc) { d.Diag = map[string][]string{"5": {"incomplete\n\x1b[2Jforged"}} },
"a diagnostic of no original": func(d *reportDoc) { d.Diag = map[string][]string{"five": {"incomplete"}} },
// Fields that speak of one another have to agree.
"a drop category with no drop": func(d *reportDoc) { d.Primary.Skipped.Dropped = []int{6} },
"a stale original it has missing": func(d *reportDoc) {
d.Primary.Missing, d.Overlays.Stale = []int{7}, []int{7}
},
"a stale original it puts nowhere": func(d *reportDoc) { d.Overlays.Stale = []int{8} },
"a changed original it defers": func(d *reportDoc) {
d.Primary.Deferred = []deferredEntry{{Original: 9, Files: []string{}, Reason: "fails the e2e"}}
d.Overlays.Changed = []int{9}
},
"a skip label on a picked original": func(d *reportDoc) { d.Overrides.Skip = []int{5} },
"a skip-label category with no label": func(d *reportDoc) { d.Primary.Skipped.SkipLabel = []int{10} },
"an include label on a rule 2 skip": func(d *reportDoc) {
d.Primary.Skipped.Type, d.Overrides.Include = []int{11}, []int{11}
},
"both labels on one original": func(d *reportDoc) {
d.Primary.Skipped.SkipLabel, d.Overrides.Include, d.Overrides.Skip = []int{12}, []int{12}, []int{12}
},
"a label on no original": func(d *reportDoc) { d.Overrides.Include = []int{13} },
"a diagnostic of an original it puts nowhere": func(d *reportDoc) { d.Diag = map[string][]string{"14": {"incomplete"}} },
"a workflow wait without the flag": func(d *reportDoc) {
d.Primary.Deferred = []deferredEntry{{Original: 15, Files: []string{".github/workflows/ci.yaml"}, Workflows: true}}
},
"a close that still picks": func(d *reportDoc) { d.Built.Options.Mode = modeClose },
"a parked original": func(d *reportDoc) { d.Primary.Parked = []json.RawMessage{json.RawMessage(`{"original":16}`)} },
} {
s := forged(change)
c, _ := reportComment(storedReport{doc: forged(func(*reportDoc) {}).doc, raw: s.raw})
comment := issueComment{ID: 1, Body: strings.Replace(c, forged(func(*reportDoc) {}).doc.ID, s.doc.ID, 1)}
comment.User.Login, comment.User.Type = "maintainer", "User"
for where, gh := range map[string]*fakeGH{
"in the body": {prs: []pullRequest{notifyPR("20261026", "text\n\n"+reportBlock(s.raw)+"\n")}},
"in a comment": {prs: []pullRequest{notifyPR("20261026", "text")}, issueComments: []issueComment{comment}},
} {
if code, stdout, stderr := reportRun(gh, "--pr", "90", "--id", s.doc.ID); code != 2 || stdout != "" {
t.Errorf("%s, %s: report = %d, want 2 and nothing printed:\n%s%s", name, where, code, stdout, stderr)
}
}
}
if code, stdout, stderr := reportRun(&fakeGH{prs: []pullRequest{notifyPR("20261026", "text\n\n"+reportBlock(forged(func(*reportDoc) {}).raw)+"\n")}}, "--pr", "90"); code != 0 || !strings.Contains(stdout, "#5 picked") {
t.Errorf("report of a sound one = %d:\n%s%s", code, stdout, stderr)
}
}
// What report prints stays one line per field and carries nothing a
// terminal acts on, whatever reached it.
func TestReportPrintsNothingThatDrivesATerminal(t *testing.T) {
const evil = "\n\x1b[2Jfake: #9 picked\u202e"
s := forged(func(d *reportDoc) {
d.Line, d.Built.At, d.Built.Options.Mode = "release-1.1"+evil, "2026-10-26"+evil, "new"+evil
d.Built.Main, d.Built.Tool, d.Built.Run, d.Built.Newer = "main"+evil, "tool"+evil, "run"+evil, "newer"+evil
d.Other = []string{"other" + evil}
d.Primary.Deferred = []deferredEntry{{Original: 6, Reason: "why" + evil}}
})
var stdout strings.Builder
a := &app{stdout: &stdout}
pr := notifyPR("20261026", "")
a.printReport(&pr, s, nil, true)
for _, ln := range strings.Split(stdout.String(), "\n") {
if strings.HasPrefix(ln, "fake") || strings.ContainsFunc(ln, func(r rune) bool { return r != '\t' && unicode.IsControl(r) || unicode.Is(unicode.Cf, r) }) {
t.Errorf("report printed %q", ln)
}
}
}
// The comment is made of what was checked: a line, a time and an ID that
// carry no markup into the summary GitHub renders.
func TestReportCommentIsBuiltFromCheckedFields(t *testing.T) {
for name, change := range map[string]func(d *reportDoc){
"markup in the line": func(d *reportDoc) { d.Line = "release-1.1</summary><img src=x onerror=alert(1)>" },
"markup in the time": func(d *reportDoc) { d.Built.At = "<b>2026-10-26</b>T06:00:00Z" },
"a mention": func(d *reportDoc) { d.Line = "release-1.1 @cozystack/maintainers" },
} {
if c, err := reportComment(forged(change)); err == nil {
t.Errorf("%s: reportComment made\n%s", name, c)
}
}
if _, err := reportComment(forged(func(*reportDoc) {})); err != nil {
t.Errorf("reportComment of a sound report: %v", err)
}
}
// Anyone can comment on a public pull request: the reader takes a report
// from a comment only when the batch trusts its author, whatever ID it
// carries.
func TestReportReaderTakesOnlyTrustedComments(t *testing.T) {
s := forged(func(*reportDoc) {})
c, err := reportComment(s)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
login, kind string
read bool
}{
{"someone", "User", false},
{"renovate[bot]", "Bot", false},
{"github-actions[bot]", "Bot", false},
{"maintainer", "User", true},
{defaultBatchBot, "Bot", true},
} {
comment := issueComment{ID: 1, Body: c}
comment.User.Login, comment.User.Type = tc.login, tc.kind
gh := &fakeGH{prs: []pullRequest{notifyPR("20261026", "text")}, issueComments: []issueComment{comment}, bot: defaultBatchBot}
code, stdout, stderr := reportRun(gh, "--pr", "90", "--id", s.doc.ID)
if read := code == 0 && strings.Contains(stdout, "#5 picked"); read != tc.read || !tc.read && (code != 2 || !strings.Contains(stderr, "keeps no report")) {
t.Errorf("a report by %s: report = %d, read %v, want %v:\n%s%s", tc.login, code, read, tc.read, stdout, stderr)
}
}
}
// A report whose ID is not one says so without repeating it as written.
func TestReportErrorsRepeatNothingRaw(t *testing.T) {
s := forged(func(*reportDoc) {})
// Short enough that a cut to 12 characters keeps the forged line.
raw := strings.Replace(s.raw, `"id":"`+s.doc.ID+`"`, `"id":"\nforged"`, 1)
code, _, stderr := reportRun(&fakeGH{prs: []pullRequest{notifyPR("20261026", "text\n\n"+reportBlock(raw)+"\n")}}, "--pr", "90")
for _, ln := range strings.Split(stderr, "\n") {
if strings.HasPrefix(ln, "forged") {
t.Errorf("report = %d, printed the forged line %q", code, ln)
}
}
if code != 2 {
t.Errorf("report = %d, want 2:\n%s", code, stderr)
}
}
+3
View File
@@ -40,6 +40,9 @@ const (
// Rule 1 for a line that started under the policy after it branched:
// merged before that start.
ruleBeforeStart = 101
// Not a rule of the policy: a maintainer dropped the original from the
// line with run --drop.
ruleDropped = 102
)
const (
+8 -3
View File
@@ -110,7 +110,8 @@ func TestWorkflowChangesWaitUntilOnTheLine(t *testing.T) {
if code != 0 || !strings.Contains(stdout, "1 originals on #90 change .github/workflows/ and wait for a maintainer") || strings.Contains(stdout, "deferred conflicts") {
t.Errorf("notify = %d, %q; want #5 for a maintainer and no conflict\n%s", code, stdout, stderr)
}
if len(gh.issueComments) != 1 || !strings.Contains(gh.issueComments[0].Body, "[#5](") || !strings.Contains(gh.issueComments[0].Body, "#changes-to-workflows") || strings.Contains(gh.issueComments[0].Body, "These conflicts") {
notes := slices.DeleteFunc(slices.Clone(gh.issueComments), func(c issueComment) bool { return !strings.HasPrefix(c.Body, notifyMarker) })
if len(notes) != 1 || !strings.Contains(notes[0].Body, "[#5](") || !strings.Contains(notes[0].Body, "#changes-to-workflows") || strings.Contains(notes[0].Body, "These conflicts") {
t.Errorf("the comment does not list #5 for a maintainer: %+v", gh.issueComments)
}
@@ -173,8 +174,12 @@ func TestDeferKeepsWorkflowChangesWaiting(t *testing.T) {
}
f.git("switch", "-q", "main")
reopen(f, gh)
if code, stdout, _ := f.exec(gh, "check", "--line", "release-1.1", "--cut"); strings.Contains(stdout, "#5 changes") {
t.Errorf("check --cut = %d, %q; a deferred #5 still stops the cut as waiting", code, stdout)
// #6 and #7 are still only on the batch, which fails the cut; #5 does not.
if code, stdout, _ := f.exec(gh, "check", "--line", "release-1.1", "--cut"); code != 1 || strings.Contains(stdout, "#5 changes") || !strings.Contains(stdout, "#6 is only on open batch PR #90") {
t.Errorf("check --cut = %d, %q; want 1 for #6 and #7 alone, a deferred #5 no longer waiting", code, stdout)
}
if code, _, stderr := f.exec(gh, "check", "--line", "release-1.1"); code != 0 || !strings.Contains(stderr, "deferred on #90 to the next batch: https://github.com/cozystack/cozystack/pull/5 ") {
t.Errorf("check = %d, want 0 with a warning about #5:\n%s", code, stderr)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ go build ./cmd/backport-batch
./backport-batch check --line release-X.Y --cut
```
[`backport-batch check`](../../cmd/backport-batch/README.md) applies the rules to every pull request merged into `main` since the line branched, and looks for each one they take on the line and then on the open batch pull request: by picks whose `-x` lines name its merge commit or every commit of its own, by a pick that would change nothing, and, on commits from before the policy's batch-only day, by what their text says, such as `Backport of #N`. With `--cut` a change found only on the open batch pull request fails the check too, since a cut tags the line and would not carry it: merge the batch pull request first. A conflict the batch deferred, or an original deferred to the next batch, prints as a warning without failing the check. A change that reached the line by hand counts like any other, by what reached the line.
[`backport-batch check`](../../cmd/backport-batch/README.md) applies the rules to every pull request merged into `main` since the line branched, and looks for each one they take on the line and then on the open batch pull request: by picks whose `-x` lines name its merge commit or every commit of its own, by a pick that would change nothing, and, on commits from before the policy's batch-only day, by what their text says, such as `Backport of #N`. With `--cut` a change found only on the open batch pull request fails the check too, since a cut tags the line and would not carry it: merge the batch pull request first. A conflict the batch deferred, or an original deferred to the next batch, prints as a warning without failing the check, also once that batch merged or closed. An original that waits on the open batch for a maintainer fails the cut. A change that reached the line by hand counts like any other, by what reached the line.
### Backporting code that touches monotonic counters
+2 -2
View File
@@ -12,7 +12,7 @@ The [backport batch](../backports.md) is built by [`backport-batch`](../../../cm
- Push nothing but the branch of the open batch pull request, `backport-batch/<line>-<yyyymmdd>` in this repository, and only what the task's check accepted. Never push to `main` or a `release-*` branch, and never open a second batch pull request: the scheduled run continues the open one.
- Change no file the original pull request does not change. A conflict that cannot be resolved inside those files is not resolved: the original stays deferred.
- Never rewrite the batch branch by hand. A plain push to it is a fast-forward; only `backport-batch run --defer --push` replaces it, leased on the head it rebuilt.
- Leave the batch pull request's title and body to the tool: it rewrites both on every run, and a hand edit is lost or, inside the `backport-batch` block, makes `check` refuse the body.
- Leave the batch pull request's title and body to the tool: it rewrites both on every run, and a hand edit is lost or, inside the `backport-batch` block, makes `run`, `check`, `fold` and `notify` refuse the body, and inside the `backport-batch-report` block makes `run --push` and `backport-batch report` refuse it. Leave the tool's report comments as they are too: each keeps one classification of the line, and an edited one makes `backport-batch report` refuse it.
- When the task cannot be done within these rules, push nothing and leave a comment on the batch pull request saying why, so the next maintainer to look finds it. Nothing is lost by waiting: a red batch does not merge, and a deferred original goes to the next batch.
### Getting the tool
@@ -26,4 +26,4 @@ cd ../backport-task
go build -o /tmp/backport-batch ./cmd/backport-batch
```
Run it from that worktree. Every command but `notify` fetches `origin` first, and `run`, `check`, `fold` and `lines` read the policy from `origin/main`.
Run it from that worktree. Every command but `notify` and `report` fetches `origin` first, and `run`, `check`, `fold` and `lines` read the policy from `origin/main`.
+2
View File
@@ -33,6 +33,8 @@ It rebuilds the branch locally without those originals and prints the new body,
It replaces the batch branch only if nobody pushed onto it since the rebuild read it, and updates the pull request body. CI runs again on the new head; if it fails again on another original, repeat from step 1.
When the culprits are everything the batch carries, there is no branch left to push: the body printed without `--push` says the batch is closing, and `--push` records the deferrals in the batch pull request, closes it and deletes its branch, still only if nobody pushed onto it since. The next batch tries them again. If it stops part way, the next `run --push` on the line finishes the close, and says so.
### Never
- Defer an original the failure does not reach, to make the batch green by taking out more than the culprit.
@@ -4,7 +4,7 @@ The batch carries a pull request merged into `main` as its own commits, each pic
### Inputs
- The open batch pull request for the line, its branch `backport-batch/<line>-<yyyymmdd>` and its head commit, as GitHub shows them: `gh pr view <batch> --json headRefName,headRefOid`.
- The open batch pull request for the line, its branch `backport-batch/<line>-<yyyymmdd>` and its head commit, as GitHub shows them: `gh pr view <batch> --json headRefName,headRefOid`. The resolution lands on that batch, so without an open batch pull request there is nothing to resolve onto: wait for the next batch, which lists the conflict again, or port the change as a pull request of its own against the line, as [fixes that exist only on a release line](../backports.md#fixes-that-exist-only-on-a-release-line) describes, and drop the original from the line.
- The original's number `N` from the **Deferred conflicts** list, and its merge commit on `main`: `gh pr view N --json mergeCommit --jq .mergeCommit.oid`. It has two parents when the pull request was merged, one when it was squashed.
- For a merged pull request, its own commits, oldest first, without the merges that brought `main` into its branch: `git rev-list --reverse --topo-order --no-merges <merge commit>^1..<merge commit>^2`.
- The files the original changes, `git diff --name-only <merge commit>^1 <merge commit>`, which are the only files the resolution may touch.
@@ -34,7 +34,7 @@ git add <resolved files>
git -c core.hooksPath=/dev/null cherry-pick --continue --no-edit
```
When the original builds on something the line does not have, a helper, a value or another pull request, the resolution would have to bring that along, which this task does not allow: abort with `git cherry-pick --abort`, go back with `git switch --detach "$base"`, and comment on the batch pull request which change the original depends on; it stays deferred until that change is on the line. A commit whose change the line already has stops the pick as empty; skip it with `git cherry-pick --skip`.
When the original builds on something the line does not have, a helper, a value or another pull request, the resolution would have to bring that along, which this task does not allow: abort with `git cherry-pick --abort`, go back with `git switch --detach "$base"`, and comment on the batch pull request which change the original depends on; it stays deferred until that change is on the line. When that change will never reach the line, drop the original there instead, with `backport-batch run --line <line> --drop N --reason "<builds on …>" --push`, so later batches stop trying it. A commit whose change the line already has stops the pick as empty; skip it with `git cherry-pick --skip`.
For a merged pull request, join the picks to the batch head with the merge commit:
+15 -3
View File
@@ -21,6 +21,10 @@ The batch takes a pull request from `main` into release line X.Y when all of the
The batch reads the pull requests off `main`'s first-parent history, one per merge commit or squash commit that names its pull request. A commit there that names none, a direct push for instance, and a merge of more than two parents are never taken; the batch pull request counts them.
### What the batch leaves out on one line
A pull request the rules take for every line can still be wrong for one of them, for instance because it builds on code that line does not have. A maintainer drops it from that line with `./backport-batch run --line <line> --drop <number> --reason <why> --push`: the batch takes it off, if it carries it, and no later batch for that line picks it again. The drop is kept in the [line's record](#the-lines-record-and-reports), so it needs an open batch pull request to record it on; before a line's first batch pull request exists there is nothing to record it on, and the command refuses. `--undrop <number>` withdraws it, and the next batch tries the pull request again. A pull request the line already carries cannot be dropped. To land a hand-made port instead, open it as a pull request of its own, as [fixes that exist only on a release line](#fixes-that-exist-only-on-a-release-line) describes, and drop the original.
### What waits for the next minor
`feat`, `chore` and `docs` pull requests, and dependency bumps (scope `deps`), ship with the next minor, not in a patch release; only a maintainer's `backport/include`, below, takes one. If a class of fixes should also wait for a minor, it gets a new rule on this page; a single pull request that the rules get wrong is what the overrides below are for.
@@ -32,6 +36,8 @@ A maintainer overrides the rules for one pull request with a label on the merged
- `backport/include` takes it although rules 2 to 4 keep it out, a `feat` for instance, or although it merged before its line started under this policy. It still has to pick cleanly or have its conflict resolved, pass CI on the batch, and, on an older line, already be on the newer one, and a line that already carries it does not take it again.
- `backport/skip` keeps it out although the rules take it, and `backport-batch check` stops asking for it. An open batch that picked it before the label came keeps it until `backport-batch run --defer` takes it out; the batch pull request says so meanwhile.
A label decides for every line. To keep a pull request out of one line only, drop it there with `backport-batch run --drop`, as [what the batch leaves out on one line](#what-the-batch-leaves-out-on-one-line) describes; `backport/include` does not lift a drop.
`backport/skip` wins when a pull request carries both. Each run reads the labels afresh, and the batch pull request lists every override and what became of it.
### Which lines the batch runs for
@@ -81,11 +87,17 @@ A run by hand trusts the open batch pull request the scheduled run opened as `co
`./backport-batch lines` prints the lines, newest first. `run`, `check`, `fold` and `lines` read the policy block from this page on `main`, so until the page with its block is on `main` they need `--policy <ref>` naming a ref that has it, and the scheduled run, which passes none, fails.
`run` picks what the rules take onto `backport-batch/<line>-<yyyymmdd>` and opens a draft pull request against the line. A pull request merged into `main` with a merge commit goes onto the batch as its own commits, oldest first, each picked with `git cherry-pick -x --signoff` so it keeps its author, its message and its sign-offs and gains the sign-off of whoever runs the batch; commits that merged `main` into the pull request's branch are left out. A merge commit joins them to the batch: it carries the original's merge message from `main` and ends in a `Backported-from: <merge commit>` trailer. A pull request squash-merged into `main` goes on as one pick. When any commit of an original conflicts, nothing of that original stays on the batch. The batch pull request is merged into the line with a merge commit, so all of this reaches the line as it is. Its body lists the originals with how many commits each brought and which of them differ from the commit they came from, the conflicts it deferred with the commit that failed and its files, and what it skipped under which rule. While that pull request is open, the next run continues it, `--continue` reading its head, rather than opening another. CI on the batch pull request runs the line's build and tests, and a maintainer merges it. Since rule 6 reads the newer line itself, an older line takes a fix in the batch after the newer line's batch with that fix has merged.
`run` picks what the rules take onto `backport-batch/<line>-<yyyymmdd>` and opens a draft pull request against the line. A pull request merged into `main` with a merge commit goes onto the batch as its own commits, oldest first, each picked with `git cherry-pick -x --signoff` so it keeps its author, its message and its sign-offs and gains the sign-off of whoever runs the batch; commits that merged `main` into the pull request's branch are left out. A merge commit joins them to the batch: it carries the original's merge message from `main` and ends in a `Backported-from: <merge commit>` trailer. A pull request squash-merged into `main` goes on as one pick. When any commit of an original conflicts, nothing of that original stays on the batch. The batch pull request is merged into the line with a merge commit, so all of this reaches the line as it is. Its body lists the originals with how many commits each brought and which of them differ from the commit they came from, the conflicts it deferred with the commit that failed and its files, and what it skipped under which rule, and ends with the [line's record and the run's report](#the-lines-record-and-reports); `--push` prints the pull request's URL. While that pull request is open, the next run continues it, `--continue` reading its head, rather than opening another. CI on the batch pull request runs the line's build and tests, and a maintainer merges it. Since rule 6 reads the newer line itself, an older line takes a fix in the batch after the newer line's batch with that fix has merged.
A deferred conflict waits for the next batch. To land it sooner, pick the original's commits onto the batch head with `git cherry-pick -x`, resolving each conflict, and join them to the batch head with a merge commit that names the original; an original squash-merged into `main` is one pick of its commit there, with no merge. Check that merge or that pick with `./backport-batch fold --line <line> --original <number> --commit <sha>`, and push it onto the batch branch, as [resolving a conflict](./backport-tasks/resolve-conflict.md) describes. CI runs `backport-batch verify` on every push to a batch pull request, which holds every original on it to the same shape. When CI on the batch pull request fails, the original it comes from is taken out with `./backport-batch run --line <line> --defer <number> --reason <why> --push` and goes to the next batch, as [attributing a failed batch CI](./backport-tasks/failed-ci.md) describes; the rest of the batch merges without it.
A deferred conflict waits for the next batch. To land it sooner, pick the original's commits onto the batch head with `git cherry-pick -x`, resolving each conflict, and join them to the batch head with a merge commit that names the original; an original squash-merged into `main` is one pick of its commit there, with no merge. Check that merge or that pick with `./backport-batch fold --line <line> --original <number> --commit <sha>`, and push it onto the batch branch, as [resolving a conflict](./backport-tasks/resolve-conflict.md) describes. CI runs `backport-batch verify` on every push to a batch pull request, which holds every original on it to the same shape. When CI on the batch pull request fails, the original it comes from is taken out with `./backport-batch run --line <line> --defer <number> --reason <why> --push` and goes to the next batch, as [attributing a failed batch CI](./backport-tasks/failed-ci.md) describes; the rest of the batch merges without it. When it is all the batch carries, `--push` records why in the batch pull request, closes it and deletes its branch, and the next batch tries it again. `--drop` and `--undrop` change the record of the open batch pull request in the same way, and need one.
Before a patch release or an rc is cut from a line, `./backport-batch check --line <line> --cut` has to exit 0. A cut tags the line, so the check fails while a pull request the rules take is not on the line, including one that is only on the open batch pull request: merge the batch pull request first. It prints the URLs, and prints what the batch deferred as warnings, since deferring is a maintainer's call. Without `--cut`, as the scheduled run uses it, a change on the open batch pull request counts as carried.
Before a patch release or an rc is cut from a line, `./backport-batch check --line <line> --cut` has to exit 0. A cut tags the line, so the check fails while a pull request the rules take is not on the line, including one that is only on the open batch pull request: merge the batch pull request first. It prints the URLs, and prints what the batch deferred as warnings, since deferring is a maintainer's call; so is a pull request labelled `backport/skip` that the open batch still carries. An original that waits on the batch for a maintainer fails the cut, also when it is all the batch has left. A cut that passes says so. Without `--cut`, as the scheduled run uses it, a change on the open batch pull request counts as carried.
### The line's record and reports
What the batch decided about a line, the conflicts it deferred, the originals taken out with `--defer` and those waiting for a maintainer, is kept in a `backport-batch` block in the body of the line's newest batch pull request, its record. `backport-batch` reads the record whether that pull request is open, merged or closed, and writes it only while the pull request is open: a merged or closed batch pull request is never edited or commented on again. So a conflict the batch deferred is still a warning in `check` after the batch merged, until a later batch tries it again. A run that picks nothing while no batch is open writes nothing, and says so. A record `backport-batch` cannot read, or one written by someone it does not trust, stops it rather than let it fall back to an older one; how it decides is in [its README](../../cmd/backport-batch/README.md#the-lines-record-and-reports).
Each run also reports where every pull request merged into `main` since the line branched stands, and what it read to decide. The body carries the latest report under the record, and a comment the tool posts on the open batch pull request keeps each one, so an earlier classification is still there after a later run replaced the body, and after the batch merged. Each drop in a report keeps its reason and the merge commit it is bound to. `./backport-batch report --pr <batch>` prints the report the body carries, and `--id <id>` an earlier one; it writes nothing and never fetches, and reads GitHub and, for a report that keeps drops, the clone's history at the heads the report was made from. A run that picks nothing while no batch is open has nowhere to keep its report: the next batch's report has the line's state.
### Changes to workflows