blob: c7181d9724d658b4f6591b23f80b0dabe29bdf90 [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package task
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"strings"
"golang.org/x/build/gerrit"
wf "golang.org/x/build/internal/workflow"
)
type GerritClient interface {
// GitilesURL returns the URL to the Gitiles server for this Gerrit instance.
GitilesURL() string
// CreateAutoSubmitChange creates a change with the given metadata and
// contents, starts trybots with auto-submit enabled, and returns its change ID.
// If the content of a file is empty, that file will be deleted from the repository.
// If the requested contents match the state of the repository, the created change
// is closed and the returned change ID will be empty.
// Consider GenerateAutoSubmitChange if the list of files that need to be updated
// isn't known ahead of time.
//
// Reviewers is the username part of a golang.org or google.com email address.
//
// As a special case for the main Go repository, if the only file content being
// updated is the special VERSION file, trybots are bypassed.
CreateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error)
// Submitted checks if the specified change has been submitted or failed
// trybots. If the CL is submitted, returns the submitted commit hash.
// If parentCommit is non-empty, the submitted CL's parent must match it.
Submitted(ctx context.Context, changeID, parentCommit string) (string, bool, error)
// GetTag returns tag information for a specified tag.
GetTag(ctx context.Context, project, tag string) (gerrit.TagInfo, error)
// Tag creates a tag on project at the specified commit.
Tag(ctx context.Context, project, tag, commit string) error
// ListTags returns all the tags on project.
ListTags(ctx context.Context, project string) ([]string, error)
// ReadBranchHead returns the head of a branch in project.
// If the branch doesn't exist, it returns an error matching gerrit.ErrResourceNotExist.
ReadBranchHead(ctx context.Context, project, branch string) (string, error)
// ListBranches returns the branch info for all the branch in a project.
ListBranches(ctx context.Context, project string) ([]gerrit.BranchInfo, error)
// CreateBranch create the given branch and returns the created branch's revision.
CreateBranch(ctx context.Context, project, branch string, input gerrit.BranchInput) (string, error)
// ListProjects lists all the projects on the server.
ListProjects(ctx context.Context) ([]string, error)
// ReadFile reads a file from project at the specified commit.
// If the file doesn't exist, it returns an error matching gerrit.ErrResourceNotExist.
ReadFile(ctx context.Context, project, commit, file string) ([]byte, error)
// ReadDir reads a directory from project at the specified commit.
// If the directory doesn't exist, it returns an error matching gerrit.ErrResourceNotExist.
ReadDir(ctx context.Context, project, commit, dir string) ([]struct{ Name string }, error)
// GetCommitsInRefs gets refs in which the specified commits were merged into.
GetCommitsInRefs(ctx context.Context, project string, commits, refs []string) (map[string][]string, error)
// QueryChanges gets changes which match the query.
QueryChanges(ctx context.Context, query string) ([]*gerrit.ChangeInfo, error)
// SetHashtags modifies the hashtags for a CL.
SetHashtags(ctx context.Context, changeID string, hashtags gerrit.HashtagsInput) error
// GetChange gets information about a specific change.
GetChange(ctx context.Context, changeID string, opts ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error)
// SubmitChange submits a specific change.
SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error)
// CreateCherryPick creates a cherry-pick change. If there are no merge
// conflicts, it starts trybots. If commitMessage is provided, the commit
// message is updated, otherwise it is taken from the original change.
// Reviewers are taken from the original change.
CreateCherryPick(ctx context.Context, changeID string, branch string, commitMessage string) (_ gerrit.ChangeInfo, conflicts bool, _ error)
// RebaseChange rebases a change onto a base revision. If revision is empty,
// the change is rebased directly on top of the target branch.
RebaseChange(ctx context.Context, changeID string, revision string) (gerrit.ChangeInfo, error)
// MoveChange moves a change onto a new branch.
MoveChange(ctx context.Context, changeID string, branch string) (gerrit.ChangeInfo, error)
// GetRevisionActions retrieves revision actions.
GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*gerrit.ActionInfo, error)
// GetCommitMessage retrieves the commit message for a change.
GetCommitMessage(ctx context.Context, changeID string) (string, error)
}
type RealGerritClient struct {
Gitiles string // Gitiles server URL, without trailing slash. For example, "https://go.googlesource.com".
Client *gerrit.Client
}
func (c *RealGerritClient) GitilesURL() string {
return c.Gitiles
}
func (c *RealGerritClient) CreateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, files map[string]string) (_ string, err error) {
defer func() {
// Check if status code is known to be not retryable.
if he := (*gerrit.HTTPError)(nil); errors.As(err, &he) && he.Res.StatusCode/100 == 4 {
ctx.DisableRetries()
}
}()
reviewerEmails, err := coordinatorEmails(reviewers)
if err != nil {
return "", err
}
change, err := c.Client.CreateChange(ctx, input)
if err != nil {
return "", err
}
anyChange := false
for path, content := range files {
var err error
if content == "" {
err = c.Client.DeleteFileInChangeEdit(ctx, change.ID, path)
} else {
err = c.Client.ChangeFileContentInChangeEdit(ctx, change.ID, path, content)
}
if errors.Is(err, gerrit.ErrNotModified) {
continue
}
if err != nil {
return "", err
}
anyChange = true
}
if !anyChange {
if err := c.Client.AbandonChange(ctx, change.ID, "no changes necessary"); err != nil {
return "", err
}
return "", nil
}
if err := c.Client.PublishChangeEdit(ctx, change.ID); err != nil {
return "", err
}
review := gerrit.ReviewInput{
Labels: map[string]int{
"Commit-Queue": 1,
"Auto-Submit": 1,
},
}
if v, ok := files["VERSION"]; input.Project == "go" && len(files) == 1 && ok && strings.HasPrefix(v, "go1.") {
// As a special case for the main Go repo, if the only change is the content
// of the VERSION file (still being some Go 1 version), opt to bypass trybots.
// This file is overwritten by golangbuild during test execution anyway, so we rely
// on tests run as part of the Go release process where the new VERSION file content
// is set precisely to match that of the upcoming Go release. See go.dev/issue/73614.
delete(review.Labels, "Commit-Queue")
review.Labels["TryBot-Bypass"] = 1
}
for _, r := range reviewerEmails {
review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: r})
}
if reviewError := c.Client.SetReview(ctx, change.ID, "current", review); reviewError != nil {
ctx.Printf("CreateAutoSubmitChange: error setting a review on change %s: %v", change.ID, reviewError)
// It's possible one of the Gerrit accounts has changed but the gophers package entry hasn't been
// updated yet. If so, try setting review again, this time without any reviewers, then keep going.
if he := (*gerrit.HTTPError)(nil); errors.As(reviewError, &he) && he.Res.StatusCode == http.StatusBadRequest {
review.Reviewers = nil
if err := c.Client.SetReview(ctx, change.ID, "current", review); err != nil {
ctx.Printf("CreateAutoSubmitChange: error setting a review on change %s without reviewers: %v", change.ID, err)
} else {
ctx.Printf("CreateAutoSubmitChange: setting a review on change %s without reviewers succeeded", change.ID)
}
}
}
return change.ID, nil
}
func (c *RealGerritClient) Submitted(ctx context.Context, changeID, parentCommit string) (string, bool, error) {
detail, err := c.Client.GetChangeDetail(ctx, changeID, gerrit.QueryChangesOpt{
Fields: []string{"CURRENT_REVISION", "DETAILED_LABELS", "CURRENT_COMMIT"},
})
if err != nil {
return "", false, err
}
if detail.Status == "MERGED" {
parents := detail.Revisions[detail.CurrentRevision].Commit.Parents
if parentCommit != "" && (len(parents) != 1 || parents[0].CommitID != parentCommit) {
return "", false, fmt.Errorf("expected merged CL %v to have one parent commit %v, has %v", ChangeLink(changeID), parentCommit, parents)
}
return detail.CurrentRevision, true, nil
}
for _, approver := range detail.Labels["TryBot-Result"].All {
if approver.Value < 0 {
return "", false, fmt.Errorf("trybots failed on %v", ChangeLink(changeID))
}
}
return "", false, nil
}
func (c *RealGerritClient) Tag(ctx context.Context, project, tag, commit string) error {
info, err := c.Client.GetTag(ctx, project, tag)
if err != nil && !errors.Is(err, gerrit.ErrResourceNotExist) {
return fmt.Errorf("checking if tag already exists: %v", err)
}
if err == nil {
if info.Revision != commit {
return fmt.Errorf("tag %q already exists on revision %q rather than our %q", tag, info.Revision, commit)
} else {
// Nothing to do.
return nil
}
}
_, err = c.Client.CreateTag(ctx, project, tag, gerrit.TagInput{
Revision: commit,
})
return err
}
func (c *RealGerritClient) ListTags(ctx context.Context, project string) ([]string, error) {
tags, err := c.Client.GetProjectTags(ctx, project)
if err != nil {
return nil, err
}
var tagNames []string
for _, tag := range tags {
tagNames = append(tagNames, strings.TrimPrefix(tag.Ref, "refs/tags/"))
}
return tagNames, nil
}
func (c *RealGerritClient) GetTag(ctx context.Context, project, tag string) (gerrit.TagInfo, error) {
return c.Client.GetTag(ctx, project, tag)
}
func (c *RealGerritClient) ReadBranchHead(ctx context.Context, project, branch string) (string, error) {
branchInfo, err := c.Client.GetBranch(ctx, project, branch)
if err != nil {
return "", err
}
return branchInfo.Revision, nil
}
func (c *RealGerritClient) ListBranches(ctx context.Context, project string) ([]gerrit.BranchInfo, error) {
return c.Client.ListBranches(ctx, project)
}
func (c *RealGerritClient) CreateBranch(ctx context.Context, project, branch string, input gerrit.BranchInput) (string, error) {
branchInfo, err := c.Client.CreateBranch(ctx, project, branch, input)
if err != nil {
return "", err
}
return branchInfo.Revision, nil
}
func (c *RealGerritClient) ListProjects(ctx context.Context) ([]string, error) {
projects, err := c.Client.ListProjects(ctx)
if err != nil {
return nil, err
}
var names []string
for _, p := range projects {
names = append(names, p.Name)
}
return names, nil
}
func (c *RealGerritClient) ReadFile(ctx context.Context, project, commit, file string) ([]byte, error) {
body, err := c.Client.GetFileContent(ctx, project, commit, file)
if err != nil {
return nil, err
}
defer body.Close()
return io.ReadAll(body)
}
func (c *RealGerritClient) ReadDir(ctx context.Context, project, commit, dir string) ([]struct{ Name string }, error) {
var resp struct {
Entries []struct{ Name string }
}
err := fetchGitilesJSON(ctx, c.Gitiles+"/"+url.PathEscape(project)+"/+/"+url.PathEscape(commit)+"/"+url.PathEscape(dir)+"?format=JSON", &resp)
if err != nil {
return nil, err
}
return resp.Entries, nil
}
func fetchGitilesJSON(ctx context.Context, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("get %q: %w", req.URL, gerrit.ErrResourceNotExist)
} else if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("did not get acceptable status code: %v body: %q", resp.Status, body)
}
if ct, want := resp.Header.Get("Content-Type"), "application/json"; ct != want {
log.Printf("fetchGitilesJSON: got response with non-'application/json' Content-Type header %q\n", ct)
if mediaType, _, err := mime.ParseMediaType(ct); err != nil {
return fmt.Errorf("bad Content-Type header %q: %v", ct, err)
} else if mediaType != "application/json" {
return fmt.Errorf("got media type %q, want %q", mediaType, "application/json")
}
}
const magicPrefix = ")]}'\n"
var buf = make([]byte, len(magicPrefix))
if _, err := io.ReadFull(resp.Body, buf); err != nil {
return err
} else if !bytes.Equal(buf, []byte(magicPrefix)) {
return fmt.Errorf("bad magic prefix")
}
return json.NewDecoder(resp.Body).Decode(v)
}
func (c *RealGerritClient) GetCommitsInRefs(ctx context.Context, project string, commits, refs []string) (map[string][]string, error) {
return c.Client.GetCommitsInRefs(ctx, project, commits, refs)
}
// ChangeLink returns a link to the review page for the CL with the specified
// change ID. The change ID must be in the project~cl# form.
func ChangeLink(changeID string) string {
parts := strings.SplitN(changeID, "~", 3)
if len(parts) != 2 {
return fmt.Sprintf("(unparseable change ID %q)", changeID)
}
return "https://go.dev/cl/" + parts[1]
}
func (c *RealGerritClient) QueryChanges(ctx context.Context, query string) ([]*gerrit.ChangeInfo, error) {
return c.Client.QueryChanges(ctx, query)
}
func (c *RealGerritClient) GetChange(ctx context.Context, changeID string, opts ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) {
return c.Client.GetChange(ctx, changeID, opts...)
}
func (c *RealGerritClient) SetHashtags(ctx context.Context, changeID string, hashtags gerrit.HashtagsInput) error {
_, err := c.Client.SetHashtags(ctx, changeID, hashtags)
return err
}
func (c *RealGerritClient) SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) {
return c.Client.SubmitChange(ctx, changeID)
}
func (c *RealGerritClient) CreateCherryPick(ctx context.Context, changeID string, branch string, commitMessage string) (gerrit.ChangeInfo, bool, error) {
cpi := gerrit.CherryPickInput{Destination: branch, KeepReviewers: true, AllowConflicts: true, Message: commitMessage}
ci, err := c.Client.CherryPickRevision(ctx, changeID, "current", cpi)
if err != nil {
return gerrit.ChangeInfo{}, false, err
}
if ci.ContainsGitConflicts {
return ci, true, nil
}
if err := c.Client.SetReview(ctx, ci.ID, "current", gerrit.ReviewInput{
Labels: map[string]int{
"Commit-Queue": 1,
},
}); err != nil {
return gerrit.ChangeInfo{}, false, err
}
return ci, false, nil
}
func (c *RealGerritClient) MoveChange(ctx context.Context, changeID string, branch string) (gerrit.ChangeInfo, error) {
return c.Client.MoveChange(ctx, changeID, gerrit.MoveInput{DestinationBranch: branch})
}
func (c *RealGerritClient) RebaseChange(ctx context.Context, changeID string, base string) (gerrit.ChangeInfo, error) {
return c.Client.RebaseChange(ctx, changeID, gerrit.RebaseInput{Base: base, AllowConflicts: true})
}
func (c *RealGerritClient) GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*gerrit.ActionInfo, error) {
return c.Client.GetRevisionActions(ctx, changeID, revision)
}
func (c *RealGerritClient) GetCommitMessage(ctx context.Context, changeID string) (string, error) {
cmi, err := c.Client.GetCommitMessage(ctx, changeID)
if err != nil {
return "", err
}
return cmi.FullMessage, nil
}