| // 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 |
| } |