mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-12 23:54:01 +09:00
feat(release): drop an original from one line with run --drop
A fix the rules take for every line can still be wrong for one of them, typically because it builds on code that line does not have. The only ways to keep it out were backport/skip, which keeps it off every line, and --defer, which holds it out of one batch: the next batch tried it again, and a conflict that would never resolve came back every run. run --drop N --reason R records a drop in the line's record, bound to the original's merge commit on main, and every later record carries it, a new batch's included. What the open batch carries of a dropped original comes off it the way --defer takes one off; any other drop changes only the record, so a batch on a line that moved since stays where it is. --undrop withdraws one. Both need an open batch, as every write to the record does. A drop comes after the rules and the carries: backport/skip, rules 2 to 4 or the line itself decide first and the drop stays on record, and backport/include does not lift it. check notes a dropped original, the body lists it, fold refuses it, and a record whose drop names another original's merge stops every command. Each report keeps the record's drops with the merge commit each is bound to and its reason, held on read to the same rules, so an earlier report still says why after an undrop. A reason now travels from a body to terminals, bodies and comments, so a reason or a file name in a record is believed only in the form a run writes it, one clean line, and so is a report's diagnostic. Output that repeats a reason, file names, a report ID or a state GitHub reports escapes it. Assisted-By: LLM Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
19 files changed
+1217
-81
No files matched your search
@@ -9,6 +9,8 @@ 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]
|
||||
@@ -19,7 +21,7 @@ go build ./cmd/backport-batch
|
||||
|
||||
[`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` and `report` fetches `origin` first; those two read 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.
|
||||
|
||||
@@ -37,11 +39,13 @@ The pull request body goes to stdout, built from the line's own `.github/PULL_RE
|
||||
|
||||
`--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.
|
||||
|
||||
`--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 [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.
|
||||
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, 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.
|
||||
|
||||
@@ -65,7 +69,7 @@ The comment starts with `<!-- backport-batch:notify -->` and is one per pull req
|
||||
|
||||
### 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 reads GitHub alone and writes nothing.
|
||||
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
|
||||
|
||||
@@ -107,11 +111,11 @@ The line's record is the `backport-batch` block of its newest batch pull request
|
||||
|
||||
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. 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 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, 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.
|
||||
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`, 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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,9 +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); !goOn {
|
||||
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)
|
||||
}
|
||||
@@ -162,6 +188,9 @@ 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 {
|
||||
@@ -221,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 {
|
||||
@@ -331,7 +360,12 @@ func (a *app) finish(b *batch, r *report, branch string, date time.Time, push, r
|
||||
// act on, with the full classification left to the report it names.
|
||||
func (r *report) lineRecord(doc reportDoc) lineRecord {
|
||||
built := doc.Built
|
||||
return lineRecord{Version: 2, Line: doc.Line, Built: &built, Report: doc.ID, Closing: r.closing, Deferred: slices.Clone(doc.Primary.Deferred), Dropped: []droppedEntry{}, Stale: slices.Clone(doc.Overlays.Stale)}
|
||||
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.
|
||||
@@ -686,7 +720,7 @@ func (a *app) finishClose(l line, url string, c closing) error {
|
||||
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, 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 {
|
||||
|
||||
@@ -221,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 {
|
||||
@@ -251,6 +251,8 @@ type report struct {
|
||||
// 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
|
||||
@@ -261,10 +263,20 @@ func ruleName(rule int) string {
|
||||
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 {
|
||||
switch rule {
|
||||
case ruleBeforeStart:
|
||||
@@ -350,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)
|
||||
@@ -358,7 +370,7 @@ 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))
|
||||
}
|
||||
@@ -372,6 +384,8 @@ func (r *report) what(details map[int]bool) 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:
|
||||
|
||||
@@ -165,6 +165,9 @@ func (b *batch) dispositions(cut bool) (map[*verdict]disposition, error) {
|
||||
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 {
|
||||
@@ -250,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: %s, %s", n, ruleName(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 != "":
|
||||
|
||||
@@ -396,6 +396,20 @@ func TestOutputNamesNoInternalRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
||||
+46
-19
@@ -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 := map[int]deferredEntry{}
|
||||
if b.src.open {
|
||||
recorded = b.src.rec.deferred()
|
||||
}
|
||||
hold := map[int]string{}
|
||||
for n, e := range recorded {
|
||||
for n, e := range b.recorded() {
|
||||
if e.Reason != "" {
|
||||
hold[n] = e.Reason
|
||||
}
|
||||
@@ -86,7 +83,15 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
|
||||
}
|
||||
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,10 +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)
|
||||
// Taking an original out changes nothing about how the batch is
|
||||
// assembled, so the record keeps the flag the batch was assembled with.
|
||||
r.opts = runOptions{NoWorkflowFiles: b.src.savedNoWorkflowFiles(), Mode: modeDefer}
|
||||
// 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)
|
||||
}
|
||||
@@ -137,7 +155,16 @@ func (a *app) rebuild(b *batch, numbers []int, reason string, push bool) int {
|
||||
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
|
||||
|
||||
@@ -684,3 +684,26 @@ func TestRefusalNamesTheMergedBatchsBranch(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -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;
|
||||
|
||||
+46
-14
@@ -34,6 +34,8 @@ 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]
|
||||
@@ -56,6 +58,11 @@ run Builds backport-batch/<line>-<yyyymmdd> in this working tree, off
|
||||
them out, the next one tries them again. --push replaces the branch,
|
||||
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
|
||||
@@ -79,7 +86,10 @@ 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 and report, which read 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
|
||||
@@ -220,8 +230,13 @@ type batch struct {
|
||||
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.
|
||||
src recordSource
|
||||
// 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
|
||||
@@ -292,16 +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 {
|
||||
if b.newerHead, err = resolve("refs/remotes/" + remote + "/" + b.newer.branch()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if newerHist, err = readHistory(b.newerHead, b.mainHead, b.textUntil); err != nil {
|
||||
if b.newerHist, err = readHistory(b.newerHead, b.mainHead, b.textUntil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -332,19 +345,33 @@ func (a *app) prepare(l line) (*batch, error) {
|
||||
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 && b.picks != nil {
|
||||
v.onBatch, _ = b.picks.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) {
|
||||
@@ -366,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
|
||||
@@ -410,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 {
|
||||
|
||||
@@ -102,6 +102,11 @@ var (
|
||||
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
|
||||
@@ -220,6 +225,10 @@ func (rec *lineRecord) validate(l line) error {
|
||||
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)
|
||||
}
|
||||
@@ -231,6 +240,9 @@ func (rec *lineRecord) validate(l line) error {
|
||||
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 {
|
||||
@@ -306,6 +318,14 @@ func (s recordSource) savedNoWorkflowFiles() bool {
|
||||
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{}
|
||||
@@ -343,10 +363,12 @@ func prState(pr *pullRequest) string {
|
||||
switch {
|
||||
case pr.MergedAt != nil:
|
||||
return "merged"
|
||||
case pr.State == "":
|
||||
case pr.State == "" || pr.State == "open":
|
||||
return "open"
|
||||
case pr.State == "closed":
|
||||
return "closed"
|
||||
}
|
||||
return pr.State
|
||||
return fmt.Sprintf("in state %q", pr.State)
|
||||
}
|
||||
|
||||
func hasRecord(body string) bool {
|
||||
|
||||
@@ -87,6 +87,12 @@ func TestRecordRefuses(t *testing.T) {
|
||||
"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)
|
||||
@@ -293,8 +299,10 @@ 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)
|
||||
@@ -304,11 +312,23 @@ func TestMetadataOperationsKeepTheMode(t *testing.T) {
|
||||
} else if rec, err := parseRecord(stdout, notifyLine); err != nil || !rec.Built.Options.NoWorkflowFiles {
|
||||
t.Fatalf("the scheduled run saved %+v, %v", rec, err)
|
||||
}
|
||||
stdout = mustRun(t, f, gh, "run", "--line", "release-1.1", "--defer", "7", "--reason", "fails the e2e", "--push")
|
||||
rec, err := parseRecord(stdout, notifyLine)
|
||||
if err != nil || rec.Built.Options != (runOptions{NoWorkflowFiles: true, Mode: modeDefer}) {
|
||||
t.Errorf("after --defer the record is %+v, %v; want the flag kept", rec.Built, 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)
|
||||
@@ -327,3 +347,22 @@ func TestMetadataOperationsKeepTheMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,8 @@ func (s *reportSkipped) list(rule int) *[]int {
|
||||
return &s.Newer
|
||||
case ruleSkipLabel:
|
||||
return &s.SkipLabel
|
||||
case ruleDropped:
|
||||
return &s.Dropped
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -180,6 +182,7 @@ func buildReport(b *batch, r *report) (reportDoc, error) {
|
||||
*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 {
|
||||
@@ -286,11 +289,14 @@ func parseReport(raw string, l line) (reportDoc, error) {
|
||||
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", 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
|
||||
}
|
||||
|
||||
@@ -317,6 +323,16 @@ func (d *reportDoc) validate(l line) error {
|
||||
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 {
|
||||
@@ -529,8 +545,10 @@ func runURL(lookup func(string) (string, bool)) string {
|
||||
return url
|
||||
}
|
||||
|
||||
// report prints a report a batch pull request keeps, reading GitHub alone and
|
||||
// writing nothing, so it works on merged and closed batches too.
|
||||
// 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")
|
||||
|
||||
@@ -250,6 +250,10 @@ func TestReportPartition(t *testing.T) {
|
||||
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)
|
||||
@@ -257,7 +261,7 @@ func TestReportPartition(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[int]string{1: "skipped.type", 4: "skipped.before-start", 5: "picked", 6: "skipped.newer", 7: "skipped.breaking",
|
||||
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 {
|
||||
@@ -269,6 +273,12 @@ func TestReportPartition(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -564,8 +574,10 @@ func TestReportRefusesWhatItCannotVouchFor(t *testing.T) {
|
||||
"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} },
|
||||
"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) {
|
||||
@@ -676,3 +688,19 @@ func TestReportReaderTakesOnlyTrustedComments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -83,7 +89,7 @@ A run by hand trusts the open batch pull request the scheduled run opened as `co
|
||||
|
||||
`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. 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.
|
||||
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; 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.
|
||||
|
||||
@@ -91,7 +97,7 @@ Before a patch release or an rc is cut from a line, `./backport-batch check --li
|
||||
|
||||
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. `./backport-batch report --pr <batch>` prints the report the body carries, and `--id <id>` an earlier one; it reads GitHub only and writes nothing. 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.
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user