blob: 1b9d0e041cfc40e730b672a0bd92cabe06fd331c [file] [log] [blame]
// Copyright 2014 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 main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/user"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
)
// auth holds cached data about authentication to Gerrit.
var auth struct {
initialized bool
host string // "go.googlesource.com"
url string // "https://go-review.googlesource.com"
project string // "go", "tools", "crypto", etc
// Authentication information.
// Either cookie name + value from git cookie file
// or username and password from .netrc.
cookieName string
cookieValue string
user string
password string
}
// loadGerritOriginMutex is used to control access when initializing auth
// in loadGerritOrigin, which can be called in parallel by "pending".
// We use a mutex rather than a sync.Once because the tests clear auth.
var loadGerritOriginMutex sync.Mutex
// loadGerritOrigin loads the Gerrit host name from the origin remote.
// This sets auth.{initialized,host,url,project}.
// If the origin remote does not appear to be a Gerrit server
// (is missing, is GitHub, is not https, has too many path elements),
// loadGerritOrigin dies.
func loadGerritOrigin() {
loadGerritOriginMutex.Lock()
defer loadGerritOriginMutex.Unlock()
if auth.initialized {
return
}
// Gerrit must be set, either explicitly via the code review config or
// implicitly as Git's origin remote.
origin := config()["gerrit"]
originUrl := trim(cmdOutput("git", "config", "remote.origin.url"))
err := loadGerritOriginInternal(origin, originUrl)
if err != nil {
dief("failed to load Gerrit origin: %v", err)
}
auth.initialized = true
}
// loadGerritOriginInternal does the work of loadGerritOrigin, just extracted out
// for easier testing.
func loadGerritOriginInternal(origin, remoteOrigin string) error {
originUrl, err := url.Parse(remoteOrigin)
if err != nil {
return fmt.Errorf("failed to parse git's remote.origin.url %q as a URL: %v", remoteOrigin, err)
} else {
originUrl.User = nil
remoteOrigin = originUrl.String()
}
hasGerritConfig := true
if origin == "" {
hasGerritConfig = false
origin = remoteOrigin
}
if strings.Contains(origin, "github.com") {
return fmt.Errorf("git origin must be a Gerrit host, not GitHub: %s", origin)
}
// Google employees are required to use sso://go/ or rpc://go/
// instead of https://go.googlesource.com/ for git operations.
// Normally that happens with a "insteadOf" in $HOME/.gitconfig,
// but in case people do a git clone from these directly, convert to
// their real meaning.
if strings.HasPrefix(origin, "sso://go/") || strings.HasPrefix(origin, "rpc://go/") {
origin = "https://go.googlesource.com/" + origin[len("sso://go/"):]
}
if googlesourceIndex := strings.Index(origin, ".googlesource.com"); googlesourceIndex >= 0 {
if !strings.HasPrefix(origin, "https://") {
return fmt.Errorf("git origin must be an https:// URL: %s", origin)
}
// Remove trailing slash from the origin, if any.
origin = strings.TrimRight(origin, "/")
// https:// prefix and then one slash between host and top-level name
if strings.Count(origin, "/") != 3 {
return fmt.Errorf("git origin is malformed: %s", origin)
}
host := origin[len("https://"):strings.LastIndex(origin, "/")]
// In the case of Google's Gerrit, host is go.googlesource.com
// and apiURL uses go-review.googlesource.com, but the Gerrit
// setup instructions do not write down a cookie explicitly for
// go-review.googlesource.com, so we look for the non-review
// host name instead.
url := origin
i := googlesourceIndex
url = url[:i] + "-review" + url[i:]
i = strings.LastIndex(url, "/")
url, project := url[:i], url[i+1:]
auth.host = host
auth.url = url
auth.project = project
return nil
}
// Origin is not *.googlesource.com.
//
// If the Gerrit origin is set from the codereview.cfg file than we handle it
// differently to allow for sub-path hosted Gerrit.
auth.host = originUrl.Host
if hasGerritConfig {
if !strings.HasPrefix(remoteOrigin, origin) {
return fmt.Errorf("Gerrit origin %q from %q different than git origin url %q", origin, configPath, originUrl)
}
auth.project = strings.Trim(strings.TrimPrefix(remoteOrigin, origin), "/")
auth.url = origin
} else {
auth.project = strings.Trim(originUrl.Path, "/")
auth.url = strings.TrimSuffix(remoteOrigin, originUrl.Path)
}
return nil
}
// testHomeDir is empty for normal use. During tests it may be set and used
// in place of the actual home directory. Tests may still need to
// set the HOME var for sub-processes such as git.
var testHomeDir = ""
func netrcName() string {
// Git on Windows will look in $HOME\_netrc.
if runtime.GOOS == "windows" {
return "_netrc"
}
return ".netrc"
}
// loadAuth loads the authentication tokens for making API calls to
// the Gerrit origin host.
func loadAuth() {
if auth.user != "" || auth.cookieName != "" {
return
}
loadGerritOrigin()
// First look in Git's http.cookiefile, which is where Gerrit
// now tells users to store this information.
if cookieFile, _ := trimErr(cmdOutputErr("git", "config", "--path", "--get-urlmatch", "http.cookiefile", auth.url)); cookieFile != "" {
data, _ := os.ReadFile(cookieFile)
maxMatch := -1
for _, line := range lines(string(data)) {
f := strings.Split(line, "\t")
if len(f) >= 7 && (f[0] == auth.host || strings.HasPrefix(f[0], ".") && strings.HasSuffix(auth.host, f[0])) {
if len(f[0]) > maxMatch {
auth.cookieName = f[5]
auth.cookieValue = f[6]
maxMatch = len(f[0])
}
}
}
if maxMatch > 0 {
return
}
}
// If not there, then look in $HOME/.netrc, which is where Gerrit
// used to tell users to store the information, until the passwords
// got so long that old versions of curl couldn't handle them.
netrc := netrcName()
homeDir := testHomeDir
if homeDir == "" {
usr, err := user.Current()
if err != nil {
dief("failed to get current user home directory to look for %q: %v", netrc, err)
}
homeDir = usr.HomeDir
}
data, _ := os.ReadFile(filepath.Join(homeDir, netrc))
for _, line := range lines(string(data)) {
if i := strings.Index(line, "#"); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) >= 6 && f[0] == "machine" && f[1] == auth.host && f[2] == "login" && f[4] == "password" {
auth.user = f[3]
auth.password = f[5]
return
}
}
dief("cannot find authentication info for %s", auth.host)
}
// gerritError is an HTTP error response served by Gerrit.
type gerritError struct {
url string
statusCode int
status string
body string
}
func (e *gerritError) Error() string {
if e.statusCode == http.StatusNotFound {
return "change not found on Gerrit server"
}
extra := strings.TrimSpace(e.body)
if extra != "" {
extra = ": " + extra
}
return fmt.Sprintf("%s%s", e.status, extra)
}
// gerritAPI executes a GET or POST request to a Gerrit API endpoint.
// It uses GET when requestBody is nil, otherwise POST. If target != nil,
// gerritAPI expects to get a 200 response with a body consisting of an
// anti-xss line (]})' or some such) followed by JSON.
// If requestBody != nil, gerritAPI sets the Content-Type to application/json.
func gerritAPI(path string, requestBody []byte, target interface{}) (err error) {
var respBodyBytes []byte
defer func() {
if err != nil {
// os.Stderr, not stderr(), because the latter is not safe for
// use from multiple goroutines.
fmt.Fprintf(os.Stderr, "git-codereview: fetch %s: %v\n", path, err)
if len(respBodyBytes) > 0 {
fmt.Fprintf(os.Stderr, "Gerrit response:\n%s\n", respBodyBytes)
}
}
}()
// Strictly speaking, we might be able to use unauthenticated
// access, by removing the /a/ from the URL, but that assumes
// that all the information we care about is publicly visible.
// Using authentication makes it possible for this to work with
// non-public CLs or Gerrit hosts too.
loadAuth()
if !strings.HasPrefix(path, "/") {
dief("internal error: gerritAPI called with malformed path")
}
url := auth.url + path
method := "GET"
var reader io.Reader
if requestBody != nil {
method = "POST"
reader = bytes.NewReader(requestBody)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
if auth.cookieName != "" {
req.AddCookie(&http.Cookie{
Name: auth.cookieName,
Value: auth.cookieValue,
})
} else {
req.SetBasicAuth(auth.user, auth.password)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
respBodyBytes = body
if err != nil {
return fmt.Errorf("reading response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return &gerritError{url, resp.StatusCode, resp.Status, string(body)}
}
if target != nil {
i := bytes.IndexByte(body, '\n')
if i < 0 {
return fmt.Errorf("%s: malformed json response - bad header", url)
}
body = body[i:]
if err := json.Unmarshal(body, target); err != nil {
return fmt.Errorf("%s: malformed json response", url)
}
}
return nil
}
// fullChangeID returns the unambigous Gerrit change ID for the commit c on branch b.
// The returned ID has the form project~originbranch~Ihexhexhexhexhex.
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id for details.
func fullChangeID(b *Branch, c *Commit) string {
loadGerritOrigin()
return auth.project + "~" + strings.TrimPrefix(b.OriginBranch(), "origin/") + "~" + c.ChangeID
}
// readGerritChange reads the metadata about a change from the Gerrit server.
// The changeID should use the syntax project~originbranch~Ihexhexhexhexhex returned
// by fullChangeID. Using only Ihexhexhexhexhex will work provided it uniquely identifies
// a single change on the server.
// The changeID can have additional query parameters appended to it, as in "normalid?o=LABELS".
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id for details.
func readGerritChange(changeID string) (*GerritChange, error) {
var c GerritChange
err := gerritAPI("/a/changes/"+changeID, nil, &c)
if err != nil {
return nil, err
}
return &c, nil
}
// readGerritChanges is like readGerritChange but expects changeID
// to be a query parameter list like q=change:XXX&q=change:YYY&o=OPTIONS,
// and it expects to receive a JSON array of GerritChanges, not just one.
func readGerritChanges(query string) ([][]*GerritChange, error) {
// The Gerrit server imposes a limit of at most 10 q= parameters.
v, err := url.ParseQuery(query)
if err != nil {
return nil, err
}
var results []chan gerritChangeResult
for len(v["q"]) > 0 {
n := len(v["q"])
if n > 10 {
n = 10
}
all := v["q"]
v["q"] = all[:n]
query := v.Encode()
v["q"] = all[n:]
ch := make(chan gerritChangeResult, 1)
go readGerritChangesBatch(query, n, ch)
results = append(results, ch)
}
var c [][]*GerritChange
for _, ch := range results {
res := <-ch
if res.err != nil {
return nil, res.err
}
c = append(c, res.c...)
}
return c, nil
}
type gerritChangeResult struct {
c [][]*GerritChange
err error
}
func readGerritChangesBatch(query string, n int, ch chan gerritChangeResult) {
var c [][]*GerritChange
// If there are multiple q=, the server sends back an array of arrays of results.
// If there is a single q=, it only sends back an array of results; in that case
// we need to do the wrapping ourselves.
var arg interface{} = &c
if n == 1 {
c = append(c, nil)
arg = &c[0]
}
err := gerritAPI("/a/changes/?"+query, nil, arg)
if len(c) != n && err == nil {
err = fmt.Errorf("gerrit result count mismatch")
}
ch <- gerritChangeResult{c, err}
}
// GerritChange is the JSON struct for a Gerrit ChangeInfo, returned by a Gerrit CL query.
type GerritChange struct {
ID string
Project string
Branch string
ChangeId string `json:"change_id"`
Subject string
Status string
Created string
Updated string
Insertions int
Deletions int
Number int `json:"_number"`
Owner *GerritAccount
Labels map[string]*GerritLabel
CurrentRevision string `json:"current_revision"`
Revisions map[string]*GerritRevision
Messages []*GerritMessage
TotalCommentCount int `json:"total_comment_count"`
UnresolvedCommentCount int `json:"unresolved_comment_count"`
}
// LabelNames returns the label names for the change, in lexicographic order.
func (g *GerritChange) LabelNames() []string {
var names []string
for name := range g.Labels {
names = append(names, name)
}
sort.Strings(names)
return names
}
// GerritMessage is the JSON struct for a Gerrit MessageInfo.
type GerritMessage struct {
Author struct {
Name string
}
Message string
}
// GerritLabel is the JSON struct for a Gerrit LabelInfo.
type GerritLabel struct {
Optional bool
Blocking bool
Approved *GerritAccount
Rejected *GerritAccount
All []*GerritApproval
}
// GerritAccount is the JSON struct for a Gerrit AccountInfo.
type GerritAccount struct {
ID int `json:"_account_id"`
Name string
Email string
Username string
}
// GerritApproval is the JSON struct for a Gerrit ApprovalInfo.
type GerritApproval struct {
GerritAccount
Value int
Date string
}
// GerritRevision is the JSON struct for a Gerrit RevisionInfo.
type GerritRevision struct {
Number int `json:"_number"`
Ref string
Fetch map[string]*GerritFetch
}
// GerritFetch is the JSON struct for a Gerrit FetchInfo.
type GerritFetch struct {
URL string
Ref string
}
// GerritComment is the JSON struct for a Gerrit CommentInfo.
type GerritComment struct {
PatchSet string `json:"patch_set"`
ID string
Path string
Side string
Parent string
Line string
Range *GerritCommentRange
InReplyTo string
Message string
Updated string
Author *GerritAccount
Tag string
Unresolved bool
ChangeMessageID string `json:"change_message_id"`
CommitID string `json:"commit_id"` // SHA1 hex
}
// GerritCommentRange is the JSON struct for a Gerrit CommentRange.
type GerritCommentRange struct {
StartLine int `json:"start_line"` // 1-based
StartCharacter int `json:"start_character"` // 0-based
EndLine int `json:"end_line"` // 1-based
EndCharacter int `json:"end_character"` // 0-based
}
// GerritContextLine is the JSON struct for a Gerrit ContextLine.
type GerritContextLine struct {
LineNumber int `json:"line_number"` // 1-based
ContextLine string `json:"context_line"`
}
// GerritCommentInput is the JSON struct for a Gerrit CommentInput.
type GerritCommentInput struct {
ID string `json:"id,omitempty"` // ID of a draft comment to update
Path string `json:"path,omitempty"` // file to attach comment to
Side string `json:"side,omitempty"` // REVISION (default) or PARENT
Line int `json:"line,omitempty"` // 0 to use range (or else file comment)
Range *GerritCommentRange `json:"range,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"` // ID of comment being replied to
Message string `json:"message,omitempty"`
Unresolved *bool `json:"unresolved,omitempty"` // defaults to parent setting or else false
}