blob: 9d9ec0b7711bc6af489cc7509bdd22f30e4492e8 [file] [log] [blame]
// Copyright 2016 The LUCI 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 gitiles
// TODO(tandrii): add tests.
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/luci/luci-go/common/errors"
"github.com/luci/luci-go/common/retry/transient"
"golang.org/x/net/context"
"golang.org/x/net/context/ctxhttp"
)
// User is the author or the committer returned from gitiles.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Time string `json:"time"`
}
// GetTime returns the Time field as real data!
func (u *User) GetTime() (time.Time, error) {
return time.Parse(time.ANSIC, u.Time)
}
// Commit is the information of a commit returned from gitiles.
type Commit struct {
Commit string `json:"commit"`
Tree string `json:"tree"`
Parents []string `json:"parents"`
Author User `json:"author"`
Committer User `json:"committer"`
Message string `json:"message"`
}
// ValidateRepoURL validates gitiles repository URL for use in this package.
func ValidateRepoURL(repoURL string) error {
_, err := NormalizeRepoURL(repoURL)
return err
}
// NormalizeRepoURL returns canonical for gitiles URL of the repo including "a/" path prefix.
// error is returned if validation fails.
func NormalizeRepoURL(repoURL string) (string, error) {
u, err := url.Parse(repoURL)
if err != nil {
return "", err
}
if u.Scheme != "https" {
return "", fmt.Errorf("%s should start with https://", repoURL)
}
if !strings.HasSuffix(u.Host, ".googlesource.com") {
return "", errors.New("only .googlesource.com repos supported")
}
if u.Fragment != "" {
return "", errors.New("no fragments allowed in repoURL")
}
if u.Path == "" || u.Path == "/" {
return "", errors.New("path to repo is empty")
}
if !strings.HasPrefix(u.Path, "/") {
u.Path = "/" + u.Path
}
if !strings.HasPrefix(u.Path, "/a/") {
// Use the authenticated URL
u.Path = "/a" + u.Path
}
u.Path = strings.TrimRight(u.Path, "/")
u.Path = strings.TrimSuffix(u.Path, ".git")
return u.String(), nil
}
// Client is Gitiles client.
type Client struct {
Client *http.Client
// Used for testing only.
mockRepoURL string
}
// Log returns a list of commits based on a repo and treeish.
// This should be equivalent of a "git log <treeish>" call in that repository.
//
// treeish can be either:
// (1) a git revision as 40-char string or its prefix so long as its unique in repo.
// (2) a ref such as "refs/heads/branch" or just "branch"
// (3) a ref defined as n-th parent of R in the form "R~n".
// For example, "master~2" or "deadbeef~1".
// (4) a range between two revisions in the form "CHILD..PREDECESSOR", where
// CHILD and PREDECESSOR are each specified in either (1), (2) or (3)
// formats listed above.
// For example, "foo..ba1", "master..refs/branch-heads/1.2.3",
// or even "master~5..master~9".
//
//
// If the returned log has a commit with 2+ parents, the order of commits after
// that is whatever Gitiles returns, which currently means ordered
// by topological sort first, and then by commit timestamps.
//
// This means that if Log(C) contains commit A, Log(A) will not necessarily return
// a subsequence of Log(C) (though definitely a subset). For example,
//
// common... -> base ------> A ----> C
// \ /
// --> B ------
//
// ----commit timestamp increases--->
//
// Log(A) = [A, base, common...]
// Log(B) = [B, base, common...]
// Log(C) = [C, A, B, base, common...]
//
func (c *Client) Log(ctx context.Context, repoURL, treeish string, limit int) ([]Commit, error) {
repoURL, err := NormalizeRepoURL(repoURL)
if err != nil {
return nil, err
}
if limit < 1 {
return nil, fmt.Errorf("limit must be at least 1, but %d provided", limit)
}
subPath := fmt.Sprintf("+log/%s?format=JSON", url.PathEscape(treeish))
resp := &logResponse{}
if err := c.get(ctx, repoURL, subPath, resp); err != nil {
return nil, err
}
result := resp.Log
for {
if resp.Next == "" || len(result) >= limit {
if len(result) > limit {
result = result[:limit]
}
return result, nil
}
nextPath := subPath + "&s=" + resp.Next
resp = &logResponse{}
if err := c.get(ctx, repoURL, nextPath, resp); err != nil {
return nil, err
}
result = append(result, resp.Log...)
}
}
////////////////////////////////////////////////////////////////////////////////
// logResponse is the JSON response from querying gitiles for a log request.
type logResponse struct {
Log []Commit `json:"log"`
Next string `json:"next"`
}
func (c *Client) get(ctx context.Context, repoURL, subPath string, result interface{}) error {
URL := fmt.Sprintf("%s/%s", repoURL, subPath)
if c.mockRepoURL != "" {
URL = fmt.Sprintf("%s/%s", c.mockRepoURL, subPath)
}
r, err := ctxhttp.Get(ctx, c.Client, URL)
if err != nil {
return transient.Tag.Apply(err)
}
defer r.Body.Close()
if r.StatusCode != 200 {
err = fmt.Errorf("failed to fetch %s, status code %d", URL, r.StatusCode)
if r.StatusCode >= 500 {
// TODO(tandrii): consider retrying.
err = transient.Tag.Apply(err)
}
return err
}
// Strip out the jsonp header, which is ")]}'"
trash := make([]byte, 4)
cnt, err := r.Body.Read(trash)
if err != nil {
return errors.Annotate(err, "unexpected response from Gitiles").Err()
}
if cnt != 4 || ")]}'" != string(trash) {
return errors.New("unexpected response from Gitiles")
}
if err = json.NewDecoder(r.Body).Decode(result); err != nil {
return errors.Annotate(err, "failed to decode Gitiles response into %T", result).Err()
}
return nil
}