Files
cozystack/cmd/backport-batch/github.go
T
Myasnikov Daniil 9f8c88b4f1 feat(release): build the backport batch with backport-batch
The rules need a tool that applies them the same way on every run and
shows what it did. backport-batch reads main's first-parent history,
judges each pull request by the policy block it reads from the page on
main, and picks what qualifies onto one batch branch per line. Each
original lands as its own commits under a merge that names it, so
authors and sign-offs survive and the line's own history shows what it
carries.

check is the gate before a cut. fold holds a hand resolution of a
conflict to the batch's shape, verify holds a whole batch to it, and
notify reports what waits on the batch for a maintainer.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-11-06 12:33:36 +00:00

314 lines
11 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"
)
// 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
// 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\]$`)
)
// 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"`
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)
// permission is login's permission on the repository as GitHub's
// legacy field gives it: admin, write, read or none.
permission(login string) (string, error)
// labelled lists the pull requests merged into main that carry label.
labelled(label string) ([]int, error)
createPull(base, head, title, bodyFile string) error
editPull(number int, title, bodyFile string) 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) 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) createPull(base, head, title, bodyFile string) error {
_, err := capture("gh", "pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", bodyFile)
return err
}
func (ghCLI) editPull(number int, title, bodyFile string) error {
_, err := capture("gh", "pr", "edit", strconv.Itoa(number), "--title", title, "--body-file", bodyFile)
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
}