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 }