Files
headscale/tools/bump/pr.go
T
Kristoffer Dalby 380d1b0d0a tools/bump: carry hand-pushed commits instead of pausing
Open PR with commits by others: rebase onto base, build on top, push
with lease. Rebase conflict pauses and comments once.
2026-10-15 10:50:32 +02:00

195 lines
5.2 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
)
// skipLabel lets a maintainer park the bot on a pull request without closing it.
const skipLabel = "no-autoupdate"
// closedCooldown is how long a rejected change is left alone. Reopening the
// same diff the next morning is how an automation earns itself an ignore rule.
const closedCooldown = 7 * 24 * time.Hour
type prState struct {
Number int `json:"number"`
State string `json:"state"`
Body string `json:"body"`
HeadRefOid string `json:"headRefOid"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
ClosedAt time.Time `json:"closedAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (p *prState) hasLabel(name string) bool {
for _, l := range p.Labels {
if l.Name == name {
return true
}
}
return false
}
// lookupPR finds the most recent pull request for branch, in any state.
func lookupPR(ctx context.Context, r *repo, slug, branch string) (*prState, error) {
out, err := r.run(ctx, "gh", "pr", "list",
"--repo", slug,
"--head", branch,
"--state", "all",
"--limit", "5",
"--json", "number,state,body,headRefOid,labels,closedAt,updatedAt")
if err != nil {
return nil, err
}
var prs []prState
if err := json.Unmarshal([]byte(out), &prs); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("decoding gh pr list: %w", err)
}
if len(prs) == 0 {
return nil, nil //nolint:nilnil // absence is the normal first-run case
}
newest := &prs[0]
for i := range prs {
if prs[i].UpdatedAt.After(newest.UpdatedAt) {
newest = &prs[i]
}
}
return newest, nil
}
// publishOptions carries everything the pull request lifecycle needs that is
// not derived from the working tree.
type publishOptions struct {
Slug string
Remote string
Branch string
Base string
Title string
Gate string
Force bool
// Lease is the branch tip the run started from, when it updates an open
// pull request; Carried lists hand-pushed commits it built on.
Lease string
Carried []string
}
// publish force-pushes the rebuilt branch and puts it in front of a human:
// reusing the open pull request if there is one, opening a new one once the
// last was merged, and staying out of the way when the last was rejected.
func publish(ctx context.Context, r *repo, opt publishOptions, results []result) error {
tree, err := treeSHA(ctx, r)
if err != nil {
return err
}
head, err := headSHA(ctx, r)
if err != nil {
return err
}
body := renderBody(results, markerOf(results, tree, head), opt.Gate, opt.Carried)
pr, err := lookupPR(ctx, r, opt.Slug, opt.Branch)
if err != nil {
return err
}
if pr != nil && pr.hasLabel(skipLabel) {
log.Printf("pull request #%d is labelled %s; leaving it alone", pr.Number, skipLabel)
return nil
}
if pr != nil && pr.State == "OPEN" {
return updateOpen(ctx, r, opt, pr, body)
}
if pr != nil && pr.State == "CLOSED" {
prev, ok := parseMarker(pr.Body)
if ok && prev.Tree == tree && !opt.Force && time.Since(pr.ClosedAt) < closedCooldown {
log.Printf("identical tree was closed unmerged in #%d on %s; skipping",
pr.Number, pr.ClosedAt.Format(time.DateOnly))
return nil
}
body = fmt.Sprintf("Previously closed unmerged: #%d\n\n%s", pr.Number, body)
}
if err := forcePush(ctx, r, opt.Remote, opt.Branch, ""); err != nil { //nolint:noinlineerr
return err
}
return createPR(ctx, r, opt, body)
}
// updateOpen refreshes an existing pull request in place. Hand-pushed commits
// were carried when the branch was built; the lease keeps the push from
// overwriting any that landed during the run.
func updateOpen(ctx context.Context, r *repo, opt publishOptions, pr *prState, body string) error {
err := forcePush(ctx, r, opt.Remote, opt.Branch, opt.Lease)
if err != nil {
return err
}
if _, err := r.run(ctx, "gh", "pr", "edit", strconv.Itoa(pr.Number), "--repo", opt.Slug, "--body", body); err != nil { //nolint:noinlineerr
return err
}
log.Printf("updated pull request #%d", pr.Number)
return nil
}
func createPR(ctx context.Context, r *repo, opt publishOptions, body string) error {
out, err := r.run(ctx, "gh", "pr", "create",
"--repo", opt.Slug,
"--base", opt.Base,
"--head", opt.Branch,
"--title", opt.Title,
"--body", body)
if err != nil {
// The branch is already pushed at this point, so a failure here costs
// the pull request, not the work. Say so: the usual cause is a token
// without pull request write, and the fix is a token change plus a
// re-run, not redoing the bump by hand.
return fmt.Errorf("%w (branch %s is pushed; open the pull request by hand or fix the token and re-run)",
err, opt.Branch)
}
log.Printf("opened pull request %s", strings.TrimSpace(out))
return nil
}
// commentOnce posts body unless the pull request already has it, so a
// condition that persists across daily runs is reported once.
func commentOnce(ctx context.Context, r *repo, slug string, number int, body string) error {
out, err := r.run(ctx, "gh", "pr", "view", strconv.Itoa(number), "--repo", slug,
"--json", "comments", "--jq", ".comments[].body")
if err != nil {
return err
}
if strings.Contains(out, body) {
return nil
}
_, err = r.run(ctx, "gh", "pr", "comment", strconv.Itoa(number), "--repo", slug, "--body", body)
return err
}