mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
docs/release/backport-tasks/failed-ci.md says to defer the culprit with run --defer --push. When the culprit was all the batch carried, the push stopped with "close batch pull request #N instead", and closing it by hand left nothing that said why: once closed, the deferred original was simply missing. The run now closes the batch itself, and does every write while the pull request is open: the report, then the body, whose record keeps the deferrals and says the batch is closing, at the head the rebuild started from. The close and the deletion of the branch go only while GitHub and the remote still show that head, and the deletion is leased on it. A close that stops part way is finished by the next run --push, before anything else, and by no other command; a head the close did not freeze is refused, never adopted. The run that finishes it stops there, and fails when it was asked for --defer, since no open batch is left to defer on. Recovery never writes the closed pull request. A same-day batch that reuses the branch name and fails to open its pull request leaves a branch the next run names for a person to delete. Assisted-By: LLM Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
394 lines
13 KiB
Go
394 lines
13 KiB
Go
/*
|
|
Copyright 2026 The Cozystack Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// openPullsCap is the page size of the open pull request listing. The REST
|
|
// listing stops at it in silence, so a full page is an error, not an answer.
|
|
const openPullsCap = 100
|
|
|
|
// recentPullsPage is the page size of the listing of every pull request
|
|
// against a line, newest first, and recentPullsPages how many pages of it a
|
|
// search for the line's record reads.
|
|
const (
|
|
recentPullsPage = 100
|
|
recentPullsPages = 10
|
|
)
|
|
|
|
// searchResultCap is the most results GitHub search returns for one query,
|
|
// however far it is paginated. gh pr list answers a --label filter through
|
|
// search, so a listing that long has to be taken as cut short.
|
|
const searchResultCap = 1000
|
|
|
|
// The labels count on a pull request merged into main, the original, and on
|
|
// no batch pull request.
|
|
const (
|
|
labelInclude = "backport/include"
|
|
labelSkip = "backport/skip"
|
|
)
|
|
|
|
var (
|
|
batchBranchRE = regexp.MustCompile(`^backport-batch/(release-\d+\.\d+)-(\d{8})$`)
|
|
loginRE = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$`)
|
|
botLoginRE = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\[bot\]$`)
|
|
pullURLRE = regexp.MustCompile(`^https://[^/\s]+/[^/\s]+/[^/\s]+/pull/([1-9][0-9]{0,8})$`)
|
|
)
|
|
|
|
// batchBotEnv names the GitHub App bot the scheduled batch opens its pull
|
|
// requests and comments as, <app slug>[bot]. The workflow sets it from the
|
|
// token it mints. Unset, it is defaultBatchBot, so that a maintainer's run by
|
|
// hand continues the scheduled batch instead of opening a second one; set and
|
|
// empty, no bot is trusted.
|
|
const (
|
|
batchBotEnv = "BACKPORT_BATCH_BOT"
|
|
defaultBatchBot = "cozystack-ci[bot]"
|
|
)
|
|
|
|
// batchBotLogin is the bot to trust given lookup, os.LookupEnv outside tests.
|
|
func batchBotLogin(lookup func(string) (string, bool)) string {
|
|
if v, ok := lookup(batchBotEnv); ok {
|
|
return v
|
|
}
|
|
return defaultBatchBot
|
|
}
|
|
|
|
// pullRequest is the REST view of a pull request: unlike gh pr list, it
|
|
// carries the author's association with the repository.
|
|
type pullRequest struct {
|
|
Number int `json:"number"`
|
|
HTMLURL string `json:"html_url"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
MergedAt *string `json:"merged_at"`
|
|
AuthorAssociation string `json:"author_association"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
Type string `json:"type"`
|
|
} `json:"user"`
|
|
Head struct {
|
|
Ref string `json:"ref"`
|
|
SHA string `json:"sha"`
|
|
Repo *struct {
|
|
FullName string `json:"full_name"`
|
|
} `json:"repo"`
|
|
} `json:"head"`
|
|
Base struct {
|
|
Ref string `json:"ref"`
|
|
Repo struct {
|
|
FullName string `json:"full_name"`
|
|
} `json:"repo"`
|
|
} `json:"base"`
|
|
}
|
|
|
|
// issueComment is the REST view of a comment on a pull request.
|
|
type issueComment struct {
|
|
ID int64 `json:"id"`
|
|
Body string `json:"body"`
|
|
User struct {
|
|
Login string `json:"login"`
|
|
Type string `json:"type"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
// github is the part of GitHub the tool talks to: gh outside tests.
|
|
type github interface {
|
|
openPulls(base string) ([]pullRequest, error)
|
|
// recentPulls is one page of every pull request against base, open,
|
|
// merged or closed, newest first.
|
|
recentPulls(base string, page int) ([]pullRequest, error)
|
|
// permission is login's permission on the repository as GitHub's
|
|
// legacy field gives it: admin, write, read or none.
|
|
permission(login string) (string, error)
|
|
// labelled lists the pull requests merged into main that carry label.
|
|
labelled(label string) ([]int, error)
|
|
// pull reads one pull request as it is now.
|
|
pull(number int) (pullRequest, error)
|
|
// createPull opens the draft and returns its number and URL.
|
|
createPull(base, head, title, bodyFile string) (int, string, error)
|
|
editPull(number int, title, bodyFile string) error
|
|
closePull(number int) error
|
|
comments(number int) ([]issueComment, error)
|
|
createComment(number int, bodyFile string) error
|
|
editComment(id int64, bodyFile string) error
|
|
// batchBot is the bot the batch writes as, from batchBotEnv, or "".
|
|
batchBot() string
|
|
}
|
|
|
|
type ghCLI struct{ bot string }
|
|
|
|
func (g ghCLI) batchBot() string { return g.bot }
|
|
|
|
func (ghCLI) openPulls(base string) ([]pullRequest, error) {
|
|
out, err := capture("gh", "api", "-X", "GET", "repos/{owner}/{repo}/pulls",
|
|
"-f", "state=open", "-f", "base="+base, "-f", "per_page="+strconv.Itoa(openPullsCap))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var prs []pullRequest
|
|
if err := json.Unmarshal([]byte(out), &prs); err != nil {
|
|
return nil, fmt.Errorf("reading the open pull requests on %s: %w", base, err)
|
|
}
|
|
if len(prs) >= openPullsCap {
|
|
return nil, fmt.Errorf("the open pull requests on %s fill a page of %d, so the list may be truncated", base, openPullsCap)
|
|
}
|
|
return prs, nil
|
|
}
|
|
|
|
func (ghCLI) recentPulls(base string, page int) ([]pullRequest, error) {
|
|
out, err := capture("gh", "api", "-X", "GET", "repos/{owner}/{repo}/pulls",
|
|
"-f", "state=all", "-f", "base="+base, "-f", "sort=created", "-f", "direction=desc",
|
|
"-f", "per_page="+strconv.Itoa(recentPullsPage), "-f", "page="+strconv.Itoa(page))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var prs []pullRequest
|
|
if err := json.Unmarshal([]byte(out), &prs); err != nil {
|
|
return nil, fmt.Errorf("reading the pull requests against %s: %w", base, err)
|
|
}
|
|
return prs, nil
|
|
}
|
|
|
|
// permCache asks GitHub for a login's permission once per command: the open
|
|
// batch's author is asked when it is chosen and again when its record is.
|
|
type permCache struct {
|
|
github
|
|
perms map[string]string
|
|
}
|
|
|
|
func (c *permCache) permission(login string) (string, error) {
|
|
if p, ok := c.perms[login]; ok {
|
|
return p, nil
|
|
}
|
|
p, err := c.github.permission(login)
|
|
if err == nil {
|
|
c.perms[login] = p
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
func (ghCLI) permission(login string) (string, error) {
|
|
if !loginRE.MatchString(login) {
|
|
return "", fmt.Errorf("%q is not a GitHub login", login)
|
|
}
|
|
out, err := capture("gh", "api", "repos/{owner}/{repo}/collaborators/"+login+"/permission")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var answer struct {
|
|
Permission string `json:"permission"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &answer); err != nil {
|
|
return "", fmt.Errorf("reading %s's permission: %w", login, err)
|
|
}
|
|
return answer.Permission, nil
|
|
}
|
|
|
|
func (ghCLI) labelled(label string) ([]int, error) {
|
|
out, err := capture("gh", "pr", "list", "--base", "main", "--state", "merged", "--label", label,
|
|
"--limit", strconv.Itoa(searchResultCap), "--json", "number")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var prs []struct {
|
|
Number int `json:"number"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &prs); err != nil {
|
|
return nil, fmt.Errorf("reading the pull requests labelled %s: %w", label, err)
|
|
}
|
|
if len(prs) >= searchResultCap {
|
|
return nil, fmt.Errorf("%d pull requests carry %s, as many as GitHub search returns, so the list may be cut short", len(prs), label)
|
|
}
|
|
numbers := make([]int, 0, len(prs))
|
|
for _, pr := range prs {
|
|
numbers = append(numbers, pr.Number)
|
|
}
|
|
return numbers, nil
|
|
}
|
|
|
|
func (ghCLI) pull(number int) (pullRequest, error) {
|
|
var pr pullRequest
|
|
out, err := capture("gh", "api", "repos/{owner}/{repo}/pulls/"+strconv.Itoa(number))
|
|
if err != nil {
|
|
return pr, err
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &pr); err != nil {
|
|
return pr, fmt.Errorf("reading pull request #%d: %w", number, err)
|
|
}
|
|
if pr.Number != number {
|
|
return pr, fmt.Errorf("asked for pull request #%d, GitHub answered with #%d", number, pr.Number)
|
|
}
|
|
return pr, nil
|
|
}
|
|
|
|
func (ghCLI) createPull(base, head, title, bodyFile string) (int, string, error) {
|
|
out, err := capture("gh", "pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", bodyFile)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
url := strings.TrimSpace(out)
|
|
m := pullURLRE.FindStringSubmatch(url)
|
|
if m == nil {
|
|
return 0, "", fmt.Errorf("gh pr create printed %q, not the URL of a pull request", url)
|
|
}
|
|
n, err := strconv.Atoi(m[1])
|
|
return n, url, err
|
|
}
|
|
|
|
func (ghCLI) editPull(number int, title, bodyFile string) error {
|
|
_, err := capture("gh", "pr", "edit", strconv.Itoa(number), "--title", title, "--body-file", bodyFile)
|
|
return err
|
|
}
|
|
|
|
func (ghCLI) closePull(number int) error {
|
|
_, err := capture("gh", "pr", "close", strconv.Itoa(number))
|
|
return err
|
|
}
|
|
|
|
func (ghCLI) comments(number int) ([]issueComment, error) {
|
|
out, err := capture("gh", "api", "--paginate", "--slurp", "repos/{owner}/{repo}/issues/"+strconv.Itoa(number)+"/comments")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pages [][]issueComment
|
|
if err := json.Unmarshal([]byte(out), &pages); err != nil {
|
|
return nil, fmt.Errorf("reading the comments on #%d: %w", number, err)
|
|
}
|
|
var all []issueComment
|
|
for _, page := range pages {
|
|
all = append(all, page...)
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
func (ghCLI) createComment(number int, bodyFile string) error {
|
|
_, err := capture("gh", "api", "--method", "POST", "repos/{owner}/{repo}/issues/"+strconv.Itoa(number)+"/comments", "-F", "body=@"+bodyFile)
|
|
return err
|
|
}
|
|
|
|
func (ghCLI) editComment(id int64, bodyFile string) error {
|
|
_, err := capture("gh", "api", "--method", "PATCH", "repos/{owner}/{repo}/issues/comments/"+strconv.FormatInt(id, 10), "-F", "body=@"+bodyFile)
|
|
return err
|
|
}
|
|
|
|
// trusted says why a pull request's author may not speak for the batch, or ""
|
|
// when they may.
|
|
func trusted(gh github, pr pullRequest) (string, error) {
|
|
why, err := trustedAuthor(gh, pr.User.Login, pr.User.Type)
|
|
if why != "" {
|
|
why = "opened by " + why
|
|
}
|
|
return why, err
|
|
}
|
|
|
|
// trustedAuthor says why an author may not speak for the batch, or "" when
|
|
// they may: the bot the batch runs as, by its exact login, or a person whose
|
|
// permission on the repository is write or admin. Any other bot is refused,
|
|
// GitHub Actions' own included: anything with a workflow's token can write as
|
|
// it. The author association is not asked: an organization member or an
|
|
// invited collaborator can hold read access only, and the legacy permission
|
|
// folds maintain into write and triage into read. A permission GitHub does not
|
|
// answer is an error, not a refusal: taken for one, it would hide the open
|
|
// batch and its comment, and the run would open a second of each.
|
|
func trustedAuthor(gh github, login, kind string) (string, error) {
|
|
switch {
|
|
case kind == "Bot" && gh.batchBot() != "" && login == gh.batchBot():
|
|
return "", nil
|
|
case kind != "User" || !loginRE.MatchString(login):
|
|
return fmt.Sprintf("%q, which is neither a person nor the batch's bot (%s)", login, batchBotEnv), nil
|
|
}
|
|
perm, err := gh.permission(login)
|
|
switch {
|
|
case err != nil:
|
|
return "", err
|
|
case perm != "write" && perm != "admin":
|
|
return fmt.Sprintf("%s, whose permission is %q", login, perm), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// batchPull picks the open batch pull request for l out of prs, or nil.
|
|
//
|
|
// A pull request counts only when its head is a batch branch for l in this
|
|
// repository, not a fork, and a trusted author opened it: anyone can open a
|
|
// pull request from a branch named like one, and its -x lines and its list of
|
|
// deferred conflicts are what check believes. Look-alikes come back as
|
|
// warnings.
|
|
func batchPull(gh github, prs []pullRequest, l line) (*pullRequest, []string, error) {
|
|
var found []*pullRequest
|
|
var warnings []string
|
|
for i := range prs {
|
|
pr := &prs[i]
|
|
m := batchBranchRE.FindStringSubmatch(pr.Head.Ref)
|
|
if m == nil || m[1] != l.branch() || pr.Base.Ref != l.branch() {
|
|
continue
|
|
}
|
|
switch {
|
|
case pr.Head.Repo == nil || pr.Head.Repo.FullName != pr.Base.Repo.FullName:
|
|
warnings = append(warnings, fmt.Sprintf("ignoring #%d: its batch branch is in a fork", pr.Number))
|
|
case checkSHA(pr.Head.SHA) != nil:
|
|
warnings = append(warnings, fmt.Sprintf("ignoring #%d: GitHub reports no head commit for it", pr.Number))
|
|
default:
|
|
why, err := trusted(gh, *pr)
|
|
if err != nil {
|
|
return nil, warnings, fmt.Errorf("batch pull request #%d: %w", pr.Number, err)
|
|
}
|
|
if why != "" {
|
|
warnings = append(warnings, fmt.Sprintf("ignoring #%d: %s", pr.Number, why))
|
|
continue
|
|
}
|
|
found = append(found, pr)
|
|
}
|
|
}
|
|
switch len(found) {
|
|
case 0:
|
|
return nil, warnings, nil
|
|
case 1:
|
|
return found[0], warnings, nil
|
|
}
|
|
return nil, warnings, fmt.Errorf("more than one open batch pull request on %s (#%d, #%d); close all but one", l.branch(), found[0].Number, found[1].Number)
|
|
}
|
|
|
|
// batchHead is the head of the batch pull request as fetched here, refused
|
|
// unless it is the commit GitHub reports for the pull request.
|
|
func batchHead(pr *pullRequest) (string, error) {
|
|
if err := checkBranch(pr.Head.Ref); err != nil {
|
|
return "", err
|
|
}
|
|
ref := "refs/remotes/" + remote + "/" + pr.Head.Ref
|
|
got, err := lookup(ref)
|
|
if err != nil || got == pr.Head.SHA {
|
|
return got, err
|
|
}
|
|
if _, err := git("fetch", "--quiet", "--end-of-options", remote, "+refs/heads/"+pr.Head.Ref+":"+ref); err != nil {
|
|
return "", err
|
|
}
|
|
if got, err = resolve(ref); err != nil {
|
|
return "", err
|
|
}
|
|
if got != pr.Head.SHA {
|
|
return "", fmt.Errorf("batch pull request #%d: %s is at %.12s, not at %.12s as GitHub reports; run again", pr.Number, pr.Head.Ref, got, pr.Head.SHA)
|
|
}
|
|
return got, nil
|
|
}
|