diff --git a/README.md b/README.md index 87fbfd6..a30e22a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # forge -Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, and Bitbucket Cloud through a single interface. +Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, and Gerrit through a single interface. ## CLI @@ -42,11 +42,12 @@ Store tokens with `forge auth login`: forge auth login # interactive: asks domain + token forge auth login --domain github.com --token ghp_abc123 forge auth login --domain gitea.example.com --token abc123 --type gitea +forge auth login --domain gerrit.example.com --token user:http_password --type gerrit ``` Check what's configured with `forge auth status`. -Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`). +Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `GERRIT_TOKEN` for `gerrit-review.googlesource.com`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`). ### Configuration @@ -64,6 +65,10 @@ token = ghp_abc123 [gitea.example.com] type = gitea token = abc123 + +[gerrit.example.com] +type = gerrit +token = user:http_password ``` `.forge` in the repo root is for per-project settings, committed to the repo, no tokens: diff --git a/detect.go b/detect.go index f227281..d553a66 100644 --- a/detect.go +++ b/detect.go @@ -1,9 +1,11 @@ package forges import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "strings" ) @@ -68,6 +70,11 @@ func detectFromAPI(ctx context.Context, client *http.Client, baseURL string) (Fo return GitLab, nil } + // Try Gerrit /config/server/version + if ok, err := probeGerritAPI(ctx, client, baseURL); err == nil && ok { + return Gerrit, nil + } + // Try GitHub Enterprise /api/v3/meta if ok, err := probeURL(ctx, client, baseURL+"/api/v3/meta"); err == nil && ok { return GitHub, nil @@ -105,6 +112,32 @@ func probeGiteaAPI(ctx context.Context, client *http.Client, baseURL string) (Fo return Gitea, nil } +func probeGerritAPI(ctx context.Context, client *http.Client, baseURL string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/config/server/version", nil) + if err != nil { + return false, err + } + + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + body = bytes.TrimPrefix(body, []byte(")]}'\n")) + + var version string + return json.Unmarshal(body, &version) == nil && version != "", nil +} + func probeURL(ctx context.Context, client *http.Client, url string) (bool, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/forge.go b/forge.go index dbef2d4..e86d8cb 100644 --- a/forge.go +++ b/forge.go @@ -141,6 +141,8 @@ func (c *Client) RegisterDomain(ctx context.Context, domain, token string, build c.forges[domain] = builders.GitLab(baseURL, token, c.httpClient) case Gitea, Forgejo: c.forges[domain] = builders.Gitea(baseURL, token, c.httpClient) + case Gerrit: + c.forges[domain] = builders.Gerrit(baseURL, token, c.httpClient) default: return fmt.Errorf("unsupported forge type %q for %s", ft, domain) } @@ -153,6 +155,7 @@ type ForgeBuilders struct { GitHub func(baseURL, token string, hc *http.Client) Forge GitLab func(baseURL, token string, hc *http.Client) Forge Gitea func(baseURL, token string, hc *http.Client) Forge + Gerrit func(baseURL, token string, hc *http.Client) Forge } func (c *Client) forgeFor(domain string) (Forge, error) { diff --git a/forges_test.go b/forges_test.go index 7329fbd..c967dd9 100644 --- a/forges_test.go +++ b/forges_test.go @@ -322,6 +322,30 @@ func TestDetectForgeTypeGitHubAPI(t *testing.T) { } } +func TestDetectForgeTypeGerritAPI(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /api/v4/version", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mux.HandleFunc("GET /config/server/version", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, ")]}'\n\"3.9.0\"") + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + ft, err := detectFromAPI(context.Background(), http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ft != Gerrit { + t.Errorf("want Gerrit, got %s", ft) + } +} + func TestClientListRepositoriesRoutes(t *testing.T) { mock := &mockForge{ repoService: &mockRepoService{ diff --git a/gerrit/branches.go b/gerrit/branches.go new file mode 100644 index 0000000..b274874 --- /dev/null +++ b/gerrit/branches.go @@ -0,0 +1,63 @@ +package gerrit + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" +) + +type gerritBranchService struct { + forge *gerritForge +} + +func (f *gerritForge) Branches() forge.BranchService { + return &gerritBranchService{forge: f} +} + +func (s *gerritBranchService) List(ctx context.Context, owner, repo string, opts forge.ListBranchOpts) ([]forge.Branch, error) { + project := projectName(owner, repo) + var infos []struct { + Ref string `json:"ref"` + Revision string `json:"revision"` + CanDelete bool `json:"can_delete"` + } + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/"+encodeID(project)+"/branches/", nil, nil, &infos); err != nil { + return nil, err + } + + branches := make([]forge.Branch, 0, len(infos)) + for _, info := range infos { + branches = append(branches, forge.Branch{ + Name: trimRefPrefix(info.Ref), + SHA: info.Revision, + }) + if opts.Limit > 0 && len(branches) >= opts.Limit { + break + } + } + return branches, nil +} + +func (s *gerritBranchService) Create(ctx context.Context, owner, repo, name, from string) (*forge.Branch, error) { + project := projectName(owner, repo) + body := map[string]string{} + if from != "" { + body["revision"] = from + } + + var info struct { + Ref string `json:"ref"` + Revision string `json:"revision"` + } + if err := s.forge.doJSON(ctx, http.MethodPut, "/projects/"+encodeID(project)+"/branches/"+encodeID(name), nil, body, &info); err != nil { + return nil, err + } + + return &forge.Branch{Name: trimRefPrefix(info.Ref), SHA: info.Revision}, nil +} + +func (s *gerritBranchService) Delete(ctx context.Context, owner, repo, name string) error { + project := projectName(owner, repo) + return s.forge.doJSON(ctx, http.MethodDelete, "/projects/"+encodeID(project)+"/branches/"+encodeID(name), nil, nil, nil) +} diff --git a/gerrit/changes.go b/gerrit/changes.go new file mode 100644 index 0000000..5044f02 --- /dev/null +++ b/gerrit/changes.go @@ -0,0 +1,291 @@ +package gerrit + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + forge "github.com/git-pkgs/forge" +) + +type gerritPRService struct { + forge *gerritForge +} + +type gerritAccountInfo struct { + ID int `json:"_account_id"` + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` +} + +type gerritRevisionInfo struct { + Number int `json:"_number"` + Ref string `json:"ref"` +} + +type gerritChangeInfo struct { + ID string `json:"id"` + Project string `json:"project"` + Branch string `json:"branch"` + ChangeID string `json:"change_id"` + Subject string `json:"subject"` + Status string `json:"status"` + Owner *gerritAccountInfo `json:"owner"` + Created string `json:"created"` + Updated string `json:"updated"` + Submitted string `json:"submitted"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` + Number int `json:"_number"` + CurrentRevision string `json:"current_revision"` + Revisions map[string]gerritRevisionInfo `json:"revisions"` + WorkInProgress bool `json:"work_in_progress"` + UnresolvedCommentCount int `json:"unresolved_comment_count"` + Messages []gerritChangeMessageInfo `json:"messages"` + MoreChanges bool `json:"_more_changes"` +} + +type gerritChangeMessageInfo struct { + ID string `json:"id"` + Author *gerritAccountInfo `json:"author"` + Date string `json:"date"` + Message string `json:"message"` +} + +func (f *gerritForge) PullRequests() forge.PullRequestService { + return &gerritPRService{forge: f} +} + +func (s *gerritPRService) convertAccount(a *gerritAccountInfo) forge.User { + if a == nil { + return forge.User{} + } + login := a.Username + if login == "" { + login = a.Email + } + if login == "" && a.ID != 0 { + login = strconv.Itoa(a.ID) + } + return forge.User{ + Login: login, + Name: a.Name, + Email: a.Email, + } +} + +func (s *gerritPRService) convertChange(c gerritChangeInfo) forge.PullRequest { + state := strings.ToLower(c.Status) + merged := false + switch c.Status { + case "NEW": + state = stateOpen + case "MERGED": + state = stateMerged + merged = true + case "ABANDONED": + state = stateClosed + } + + head := forge.PRBranch{SHA: c.CurrentRevision} + if rev, ok := c.Revisions[c.CurrentRevision]; ok { + head.Ref = rev.Ref + } + result := forge.PullRequest{ + Number: c.Number, + Title: c.Subject, + State: state, + Draft: c.WorkInProgress, + Author: s.convertAccount(c.Owner), + Head: head, + Base: forge.PRBranch{Ref: c.Branch}, + Merged: merged, + Comments: len(c.Messages), + Additions: c.Insertions, + Deletions: c.Deletions, + HTMLURL: s.changeURL(c.Project, c.Number), + CreatedAt: parseGerritTime(c.Created), + UpdatedAt: parseGerritTime(c.Updated), + ChangedFiles: 0, + } + if c.Submitted != "" { + t := parseGerritTime(c.Submitted) + result.MergedAt = &t + } + return result +} + +func (s *gerritPRService) changeURL(project string, number int) string { + if project == "" { + return s.forge.baseURL + "/c/" + strconv.Itoa(number) + } + parts := strings.Split(project, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return s.forge.baseURL + "/c/" + strings.Join(parts, "/") + "/+/" + strconv.Itoa(number) +} + +func (s *gerritPRService) Get(ctx context.Context, owner, repo string, number int) (*forge.PullRequest, error) { + query := url.Values{} + query.Add("o", "DETAILED_ACCOUNTS") + query.Add("o", "CURRENT_REVISION") + query.Add("o", "MESSAGES") + + var c gerritChangeInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/changes/"+encodeID(strconv.Itoa(number))+"/detail", query, nil, &c); err != nil { + return nil, err + } + result := s.convertChange(c) + return &result, nil +} + +func (s *gerritPRService) List(ctx context.Context, owner, repo string, opts forge.ListPROpts) ([]forge.PullRequest, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + start := 0 + if opts.Page > 1 { + start = (opts.Page - 1) * perPage + } + + var all []forge.PullRequest + for { + query := url.Values{} + query.Set("q", s.query(owner, repo, opts)) + query.Set("n", intString(perPage)) + query.Set("S", intString(start)) + query.Add("o", "DETAILED_ACCOUNTS") + query.Add("o", "CURRENT_REVISION") + + var page []gerritChangeInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/changes/", query, nil, &page); err != nil { + return nil, err + } + more := false + for _, c := range page { + if c.MoreChanges { + more = true + } + all = append(all, s.convertChange(c)) + if opts.Limit > 0 && len(all) >= opts.Limit { + return all[:opts.Limit], nil + } + } + if !more || len(page) == 0 { + break + } + start += perPage + } + return all, nil +} + +func (s *gerritPRService) query(owner, repo string, opts forge.ListPROpts) string { + terms := []string{"project:" + projectName(owner, repo)} + switch opts.State { + case "", stateOpen: + terms = append(terms, "status:open") + case stateMerged: + terms = append(terms, "status:merged") + case stateClosed: + terms = append(terms, "status:closed") + case stateAll: + default: + terms = append(terms, "status:"+opts.State) + } + if opts.Author != "" { + terms = append(terms, "owner:"+opts.Author) + } + if opts.Base != "" { + terms = append(terms, "branch:"+opts.Base) + } + return strings.Join(terms, " ") +} + +func (s *gerritPRService) Create(_ context.Context, _, _ string, _ forge.CreatePROpts) (*forge.PullRequest, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritPRService) Update(_ context.Context, _, _ string, _ int, _ forge.UpdatePROpts) (*forge.PullRequest, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritPRService) Close(ctx context.Context, owner, repo string, number int) error { + body := map[string]string{"message": "Abandoned by forge"} + return s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/abandon", nil, body, nil) +} + +func (s *gerritPRService) Reopen(ctx context.Context, owner, repo string, number int) error { + body := map[string]string{"message": "Restored by forge"} + return s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/restore", nil, body, nil) +} + +func (s *gerritPRService) Merge(ctx context.Context, owner, repo string, number int, opts forge.MergePROpts) error { + body := map[string]string{} + if opts.Message != "" { + body["message"] = opts.Message + } + return s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/submit", nil, body, nil) +} + +func (s *gerritPRService) Diff(ctx context.Context, owner, repo string, number int) (string, error) { + value, err := s.forge.doText(ctx, http.MethodGet, "/changes/"+encodeID(strconv.Itoa(number))+"/revisions/current/patch", nil, nil) + if err != nil { + return "", err + } + return decodeBase64Text(value) +} + +func (s *gerritPRService) CreateComment(ctx context.Context, owner, repo string, number int, body string) (*forge.Comment, error) { + input := map[string]string{"message": body} + if err := s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/revisions/current/review", nil, input, nil); err != nil { + return nil, err + } + now := time.Now().UTC() + return &forge.Comment{ + Body: body, + HTMLURL: s.changeURL(projectName(owner, repo), number), + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (s *gerritPRService) ListComments(ctx context.Context, owner, repo string, number int) ([]forge.Comment, error) { + var messages []gerritChangeMessageInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/changes/"+encodeID(strconv.Itoa(number))+"/messages", nil, nil, &messages); err != nil { + return nil, err + } + comments := make([]forge.Comment, 0, len(messages)) + for _, msg := range messages { + comments = append(comments, forge.Comment{ + Body: msg.Message, + Author: s.convertAccount(msg.Author), + HTMLURL: s.changeURL(projectName(owner, repo), number), + CreatedAt: parseGerritTime(msg.Date), + UpdatedAt: parseGerritTime(msg.Date), + }) + } + return comments, nil +} + +func (s *gerritPRService) ListReactions(_ context.Context, _, _ string, _ int, _ int64) ([]forge.Reaction, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritPRService) AddReaction(_ context.Context, _, _ string, _ int, _ int64, _ string) (*forge.Reaction, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritPRService) ListURL(repoHTMLURL string) string { + if project, ok := strings.CutPrefix(repoHTMLURL, s.forge.baseURL+"/admin/repos/"); ok { + if decoded, err := url.PathUnescape(project); err == nil { + return s.forge.baseURL + "/q/project:" + url.QueryEscape(decoded) + } + } + return strings.TrimRight(repoHTMLURL, "/") + "/+/changes" +} diff --git a/gerrit/files.go b/gerrit/files.go new file mode 100644 index 0000000..77a5e64 --- /dev/null +++ b/gerrit/files.go @@ -0,0 +1,41 @@ +package gerrit + +import ( + "context" + "net/http" + "path" + + forge "github.com/git-pkgs/forge" +) + +type gerritFileService struct { + forge *gerritForge +} + +func (f *gerritForge) Files() forge.FileService { + return &gerritFileService{forge: f} +} + +func (s *gerritFileService) Get(ctx context.Context, owner, repo, filePath, ref string) (*forge.FileContent, error) { + project := projectName(owner, repo) + if ref == "" { + ref = "HEAD" + } + value, err := s.forge.doText(ctx, http.MethodGet, "/projects/"+encodeID(project)+"/branches/"+encodeID(ref)+"/files/"+encodeID(filePath)+"/content", nil, nil) + if err != nil { + return nil, err + } + content, err := decodeBase64Text(value) + if err != nil { + return nil, err + } + return &forge.FileContent{ + Name: path.Base(filePath), + Path: filePath, + Content: []byte(content), + }, nil +} + +func (s *gerritFileService) List(_ context.Context, _, _, _, _ string) ([]forge.FileEntry, error) { + return nil, forge.ErrNotSupported +} diff --git a/gerrit/gerrit.go b/gerrit/gerrit.go new file mode 100644 index 0000000..4c3afa5 --- /dev/null +++ b/gerrit/gerrit.go @@ -0,0 +1,233 @@ +package gerrit + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/url" + "path" + "sort" + "strconv" + "strings" + "time" + + forge "github.com/git-pkgs/forge" +) + +const ( + defaultPageSize = 100 + stateAll = "all" + stateClosed = "closed" + stateMerged = "merged" + stateOpen = "open" +) + +type gerritForge struct { + baseURL string + token string + httpClient *http.Client +} + +// New creates a Gerrit forge backend. +func New(baseURL, token string, hc *http.Client) forge.Forge { + if hc == nil { + hc = http.DefaultClient + } + return &gerritForge{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: hc, + } +} + +func (f *gerritForge) endpoint(apiPath string, values url.Values) string { + prefix := "" + if f.token != "" { + prefix = "/a" + } + u := f.baseURL + prefix + apiPath + if len(values) > 0 { + u += "?" + values.Encode() + } + return u +} + +func (f *gerritForge) doJSON(ctx context.Context, method, apiPath string, query url.Values, body any, v any) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, f.endpoint(apiPath, query), bodyReader) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + f.authorize(req) + + resp, err := f.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + if resp.StatusCode == http.StatusNoContent { + return nil + } + if resp.StatusCode >= http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + return &forge.HTTPError{StatusCode: resp.StatusCode, URL: req.URL.String(), Body: string(respBody)} + } + if v == nil { + return nil + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(stripXSSI(respBody), v) +} + +func (f *gerritForge) doText(ctx context.Context, method, apiPath string, query url.Values, body any) (string, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return "", err + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, f.endpoint(apiPath, query), bodyReader) + if err != nil { + return "", err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + f.authorize(req) + + resp, err := f.httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return "", forge.ErrNotFound + } + if resp.StatusCode >= http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + return "", &forge.HTTPError{StatusCode: resp.StatusCode, URL: req.URL.String(), Body: string(respBody)} + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(stripXSSI(respBody)), nil +} + +func (f *gerritForge) authorize(req *http.Request) { + if f.token == "" { + return + } + if user, password, ok := strings.Cut(f.token, ":"); ok { + req.SetBasicAuth(user, password) + return + } + req.Header.Set("Authorization", "Bearer "+f.token) +} + +func stripXSSI(body []byte) []byte { + return bytes.TrimPrefix(body, []byte(")]}'\n")) +} + +func encodeID(id string) string { + return url.PathEscape(id) +} + +func projectName(owner, repo string) string { + if owner == "" { + return repo + } + return owner + "/" + repo +} + +func splitProjectName(project string) (owner, repo string) { + project = strings.Trim(project, "/") + if project == "" { + return "", "" + } + owner, repo = path.Split(project) + return strings.TrimSuffix(owner, "/"), repo +} + +func trimRefPrefix(ref string) string { + for _, prefix := range []string{"refs/heads/", "refs/tags/"} { + if strings.HasPrefix(ref, prefix) { + return strings.TrimPrefix(ref, prefix) + } + } + return ref +} + +func parseGerritTime(value string) time.Time { + if value == "" { + return time.Time{} + } + for _, layout := range []string{ + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05", + time.RFC3339Nano, + time.RFC3339, + } { + if t, err := time.ParseInLocation(layout, value, time.UTC); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +func decodeBase64Text(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { + return string(decoded), nil + } + decoded, err := base64.RawStdEncoding.DecodeString(value) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func sortedMapKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func intString(n int) string { + return strconv.Itoa(n) +} diff --git a/gerrit/gerrit_test.go b/gerrit/gerrit_test.go new file mode 100644 index 0000000..a99b972 --- /dev/null +++ b/gerrit/gerrit_test.go @@ -0,0 +1,250 @@ +package gerrit + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + forge "github.com/git-pkgs/forge" +) + +const xssi = ")]}'\n" + +func newTestForge(handler http.Handler) (*gerritForge, func()) { + srv := httptest.NewServer(handler) + f := New(srv.URL, "", srv.Client()).(*gerritForge) + return f, srv.Close +} + +func TestRepoGetStripsXSSIAndMapsProject(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /projects/org%2Frepo", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, xssi+`{ + "id":"org%2Frepo", + "name":"org/repo", + "description":"demo", + "state":"ACTIVE" + }`) + }) + mux.HandleFunc("GET /projects/org%2Frepo/HEAD", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, xssi+`"refs/heads/main"`) + }) + f, done := newTestForge(mux) + defer done() + + repo, err := f.Repos().Get(context.Background(), "org", "repo") + if err != nil { + t.Fatalf("Get: %v", err) + } + if repo.FullName != "org/repo" || repo.Owner != "org" || repo.Name != "repo" { + t.Fatalf("unexpected repo identity: %+v", repo) + } + if repo.Description != "demo" { + t.Fatalf("Description = %q, want demo", repo.Description) + } + if repo.DefaultBranch != "main" { + t.Fatalf("DefaultBranch = %q, want main", repo.DefaultBranch) + } + if repo.HasIssues { + t.Fatalf("Gerrit repo should not advertise issues") + } + if !repo.PullRequestsEnabled { + t.Fatalf("Gerrit changes should map to pull requests") + } +} + +func TestRepoListUsesProjectPrefixAndLimit(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /projects/", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("p"); got != "team/" { + t.Fatalf("prefix query = %q, want team/", got) + } + if got := r.URL.Query().Get("n"); got != "2" { + t.Fatalf("page size = %q, want 2", got) + } + _, _ = fmt.Fprint(w, xssi+`{ + "team/a":{"id":"team%2Fa","description":"A"}, + "team/b":{"id":"team%2Fb","description":"B","_more_projects":true} + }`) + }) + f, done := newTestForge(mux) + defer done() + + repos, err := f.Repos().List(context.Background(), "team", forge.ListRepoOpts{PerPage: 2, Limit: 2}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(repos) != 2 || repos[0].FullName != "team/a" || repos[1].FullName != "team/b" { + t.Fatalf("unexpected repos: %+v", repos) + } +} + +func TestBranchesAndTags(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /projects/org%2Frepo/branches/", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, xssi+`[ + {"ref":"refs/heads/main","revision":"abc"}, + {"ref":"refs/heads/dev","revision":"def"} + ]`) + }) + mux.HandleFunc("GET /projects/org%2Frepo/tags/", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, xssi+`[ + {"ref":"refs/tags/v1.0.0","revision":"111"} + ]`) + }) + f, done := newTestForge(mux) + defer done() + + branches, err := f.Branches().List(context.Background(), "org", "repo", forge.ListBranchOpts{Limit: 1}) + if err != nil { + t.Fatalf("branches List: %v", err) + } + if len(branches) != 1 || branches[0].Name != "main" || branches[0].SHA != "abc" { + t.Fatalf("unexpected branches: %+v", branches) + } + + tags, err := f.Repos().ListTags(context.Background(), "org", "repo") + if err != nil { + t.Fatalf("ListTags: %v", err) + } + if len(tags) != 1 || tags[0].Name != "v1.0.0" || tags[0].Commit != "111" { + t.Fatalf("unexpected tags: %+v", tags) + } +} + +func TestFileGetDecodesBase64(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /projects/org%2Frepo/branches/main/files/README.md/content", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, base64.StdEncoding.EncodeToString([]byte("hello\n"))) + }) + f, done := newTestForge(mux) + defer done() + + file, err := f.Files().Get(context.Background(), "org", "repo", "README.md", "main") + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(file.Content) != "hello\n" { + t.Fatalf("content = %q, want hello newline", string(file.Content)) + } +} + +func TestPullRequestGetAndDiff(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /changes/42/detail", func(w http.ResponseWriter, r *http.Request) { + options := r.URL.Query()["o"] + if len(options) == 0 { + t.Fatalf("expected Gerrit option query values") + } + _, _ = fmt.Fprint(w, xssi+`{ + "id":"org%2Frepo~main~Iabc", + "project":"org/repo", + "branch":"main", + "change_id":"Iabc", + "subject":"Add Gerrit", + "status":"MERGED", + "owner":{"_account_id":7,"username":"alice","name":"Alice"}, + "created":"2026-03-10 16:09:43.000000000", + "updated":"2026-03-11 16:09:43.000000000", + "submitted":"2026-03-12 16:09:43.000000000", + "_number":42, + "insertions":10, + "deletions":2, + "current_revision":"deadbeef", + "revisions":{"deadbeef":{"_number":1,"ref":"refs/changes/42/42/1"}}, + "messages":[{"id":"m1","message":"Uploaded patch set 1"}] + }`) + }) + mux.HandleFunc("GET /changes/42/revisions/current/patch", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, base64.StdEncoding.EncodeToString([]byte("diff --git a/a b/a\n"))) + }) + f, done := newTestForge(mux) + defer done() + + pr, err := f.PullRequests().Get(context.Background(), "org", "repo", 42) + if err != nil { + t.Fatalf("Get: %v", err) + } + if pr.State != "merged" || !pr.Merged || pr.Number != 42 { + t.Fatalf("unexpected PR state: %+v", pr) + } + if pr.Author.Login != "alice" { + t.Fatalf("author = %+v, want alice", pr.Author) + } + if pr.Head.SHA != "deadbeef" || pr.Head.Ref != "refs/changes/42/42/1" { + t.Fatalf("head = %+v", pr.Head) + } + + diff, err := f.PullRequests().Diff(context.Background(), "org", "repo", 42) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if diff != "diff --git a/a b/a\n" { + t.Fatalf("diff = %q", diff) + } +} + +func TestPullRequestListBuildsGerritQuery(t *testing.T) { + mux := http.NewServeMux() + requests := 0 + mux.HandleFunc("GET /changes/", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 1 { + _, _ = fmt.Fprint(w, xssi+`[]`) + return + } + got, _ := url.QueryUnescape(r.URL.Query().Get("q")) + want := "project:org/repo status:open owner:alice branch:main" + if got != want { + t.Fatalf("query = %q, want %q", got, want) + } + _, _ = fmt.Fprint(w, xssi+`[ + {"project":"org/repo","branch":"main","subject":"One","status":"NEW","_number":1,"_more_changes":true} + ]`) + }) + f, done := newTestForge(mux) + defer done() + + prs, err := f.PullRequests().List(context.Background(), "org", "repo", forge.ListPROpts{ + State: "open", + Author: "alice", + Base: "main", + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(prs) != 1 || prs[0].State != "open" { + t.Fatalf("unexpected PRs: %+v", prs) + } +} + +func TestUnsupportedServicesReturnErrNotSupported(t *testing.T) { + f, done := newTestForge(http.NewServeMux()) + defer done() + + _, err := f.Issues().List(context.Background(), "org", "repo", forge.ListIssueOpts{}) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("Issues().List error = %v, want ErrNotSupported", err) + } + + _, err = f.PullRequests().Create(context.Background(), "org", "repo", forge.CreatePROpts{}) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("PullRequests().Create error = %v, want ErrNotSupported", err) + } +} + +func TestParsePath(t *testing.T) { + f := New("https://gerrit.example.com", "", nil) + ref, err := f.ParsePath([]string{"c", "plugins", "replication", "+", "123"}) + if err != nil { + t.Fatalf("ParsePath: %v", err) + } + if ref.Owner != "plugins" || ref.Repo != "replication" || ref.Type != forge.ResourceTypePR || ref.Number != 123 { + t.Fatalf("unexpected ref: %+v", ref) + } +} diff --git a/gerrit/repos.go b/gerrit/repos.go new file mode 100644 index 0000000..ef462ed --- /dev/null +++ b/gerrit/repos.go @@ -0,0 +1,262 @@ +package gerrit + +import ( + "context" + "net/http" + "net/url" + "strings" + + forge "github.com/git-pkgs/forge" +) + +type gerritRepoService struct { + forge *gerritForge +} + +type gerritProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Parent string `json:"parent"` + Description string `json:"description"` + State string `json:"state"` + Branches map[string]string `json:"branches"` + MoreProjects bool `json:"_more_projects"` +} + +func (f *gerritForge) Repos() forge.RepoService { + return &gerritRepoService{forge: f} +} + +func (s *gerritRepoService) convertProject(p gerritProjectInfo) forge.Repository { + name := p.Name + if name == "" { + name = p.ID + } + name = strings.ReplaceAll(name, "%2F", "/") + owner, repo := splitProjectName(name) + + result := forge.Repository{ + FullName: name, + Owner: owner, + Name: repo, + Description: p.Description, + HTMLURL: s.forge.baseURL + "/admin/repos/" + encodeID(name), + CloneURL: s.forge.baseURL + "/" + name, + DefaultBranch: trimRefPrefix(p.Branches["HEAD"]), + Archived: p.State == "READ_ONLY", + Private: p.State == "HIDDEN", + HasIssues: false, + PullRequestsEnabled: true, + } + if result.DefaultBranch == "" { + result.DefaultBranch = trimRefPrefix(p.Branches["master"]) + } + return result +} + +func (s *gerritRepoService) Get(ctx context.Context, owner, repo string) (*forge.Repository, error) { + project := projectName(owner, repo) + var p gerritProjectInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/"+encodeID(project), nil, nil, &p); err != nil { + return nil, err + } + if p.Name == "" { + p.Name = project + } + + var head string + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/"+encodeID(project)+"/HEAD", nil, nil, &head); err == nil { + if p.Branches == nil { + p.Branches = make(map[string]string) + } + p.Branches["HEAD"] = head + } + + result := s.convertProject(p) + return &result, nil +} + +func (s *gerritRepoService) List(ctx context.Context, owner string, opts forge.ListRepoOpts) ([]forge.Repository, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = defaultPageSize + } + start := 0 + if opts.Page > 1 { + start = (opts.Page - 1) * perPage + } + + var repos []forge.Repository + for { + query := url.Values{} + query.Set("d", "") + query.Set("n", intString(perPage)) + query.Set("S", intString(start)) + if owner != "" { + query.Set("p", owner+"/") + } + + var page map[string]gerritProjectInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/", query, nil, &page); err != nil { + return nil, err + } + + more := false + for _, name := range sortedMapKeys(page) { + p := page[name] + if p.MoreProjects { + more = true + } + if p.Name == "" { + p.Name = name + } + repos = append(repos, s.convertProject(p)) + if opts.Limit > 0 && len(repos) >= opts.Limit { + return forge.FilterRepos(repos[:opts.Limit], opts), nil + } + } + if !more || len(page) == 0 { + break + } + start += perPage + } + + return forge.FilterRepos(repos, opts), nil +} + +func (s *gerritRepoService) Create(ctx context.Context, opts forge.CreateRepoOpts) (*forge.Repository, error) { + name := opts.Name + if opts.Owner != "" { + name = opts.Owner + "/" + opts.Name + } + body := map[string]any{} + if opts.Description != "" { + body["description"] = opts.Description + } + if opts.DefaultBranch != "" { + body["branches"] = []string{opts.DefaultBranch} + } + + var p gerritProjectInfo + if err := s.forge.doJSON(ctx, http.MethodPut, "/projects/"+encodeID(name), nil, body, &p); err != nil { + return nil, err + } + if p.Name == "" { + p.Name = name + } + result := s.convertProject(p) + return &result, nil +} + +func (s *gerritRepoService) Edit(ctx context.Context, owner, repo string, opts forge.EditRepoOpts) (*forge.Repository, error) { + project := projectName(owner, repo) + if opts.Description != nil { + body := map[string]string{"description": *opts.Description} + if err := s.forge.doJSON(ctx, http.MethodPut, "/projects/"+encodeID(project)+"/description", nil, body, nil); err != nil { + return nil, err + } + } + if opts.DefaultBranch != nil { + body := map[string]string{"ref": *opts.DefaultBranch} + if !strings.HasPrefix(*opts.DefaultBranch, "refs/") { + body["ref"] = "refs/heads/" + *opts.DefaultBranch + } + if err := s.forge.doJSON(ctx, http.MethodPut, "/projects/"+encodeID(project)+"/HEAD", nil, body, nil); err != nil { + return nil, err + } + } + return s.Get(ctx, owner, repo) +} + +func (s *gerritRepoService) Delete(_ context.Context, _, _ string) error { + return forge.ErrNotSupported +} + +func (s *gerritRepoService) Fork(_ context.Context, _, _ string, _ forge.ForkRepoOpts) (*forge.Repository, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritRepoService) ListForks(_ context.Context, _, _ string, _ forge.ListForksOpts) ([]forge.Repository, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritRepoService) ListTags(ctx context.Context, owner, repo string) ([]forge.Tag, error) { + project := projectName(owner, repo) + var infos []struct { + Ref string `json:"ref"` + Revision string `json:"revision"` + } + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/"+encodeID(project)+"/tags/", nil, nil, &infos); err != nil { + return nil, err + } + + tags := make([]forge.Tag, len(infos)) + for i, info := range infos { + tags[i] = forge.Tag{Name: trimRefPrefix(info.Ref), Commit: info.Revision} + } + return tags, nil +} + +func (s *gerritRepoService) ListContributors(_ context.Context, _, _ string) ([]forge.Contributor, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritRepoService) Search(ctx context.Context, opts forge.SearchRepoOpts) ([]forge.Repository, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = defaultPageSize + } + start := 0 + if opts.Page > 1 { + start = (opts.Page - 1) * perPage + } + + var repos []forge.Repository + for { + query := url.Values{} + query.Set("query", opts.Query) + query.Set("limit", intString(perPage)) + query.Set("start", intString(start)) + + var page []gerritProjectInfo + if err := s.forge.doJSON(ctx, http.MethodGet, "/projects/", query, nil, &page); err != nil { + return nil, err + } + more := false + for _, p := range page { + if p.MoreProjects { + more = true + } + repos = append(repos, s.convertProject(p)) + if opts.Limit > 0 && len(repos) >= opts.Limit { + return repos[:opts.Limit], nil + } + } + if !more || len(page) == 0 { + break + } + start += perPage + } + return repos, nil +} + +func (s *gerritRepoService) SettingsURL(repoHTMLURL string) string { + return repoHTMLURL +} + +func (s *gerritRepoService) WikiURL(repoHTMLURL string) string { + return repoHTMLURL +} + +func (s *gerritRepoService) ActionsURL(repoHTMLURL string) string { + return repoHTMLURL +} + +func (s *gerritRepoService) ReleasesURL(repoHTMLURL string) string { + return repoHTMLURL +} + +func (s *gerritRepoService) BlobURL(repoHTMLURL, ref, filePath string) string { + base := strings.TrimRight(repoHTMLURL, "/") + return base + "/+/refs/heads/" + url.PathEscape(ref) + "/" + strings.TrimLeft(filePath, "/") +} diff --git a/gerrit/reviews.go b/gerrit/reviews.go new file mode 100644 index 0000000..074151c --- /dev/null +++ b/gerrit/reviews.go @@ -0,0 +1,76 @@ +package gerrit + +import ( + "context" + "net/http" + "strconv" + "time" + + forge "github.com/git-pkgs/forge" +) + +type gerritReviewService struct { + forge *gerritForge +} + +func (f *gerritForge) Reviews() forge.ReviewService { + return &gerritReviewService{forge: f} +} + +func (s *gerritReviewService) List(_ context.Context, _, _ string, _ int, _ forge.ListReviewOpts) ([]forge.Review, error) { + return nil, forge.ErrNotSupported +} + +func (s *gerritReviewService) Submit(ctx context.Context, owner, repo string, number int, opts forge.SubmitReviewOpts) (*forge.Review, error) { + state := opts.State + if state == "" { + state = forge.ReviewCommented + } + + body := map[string]any{} + if opts.Body != "" { + body["message"] = opts.Body + } + labels := map[string]string{} + switch state { + case forge.ReviewApproved: + labels["Code-Review"] = "+2" + case forge.ReviewChangesRequested: + labels["Code-Review"] = "-2" + case forge.ReviewCommented: + default: + return nil, forge.ErrNotSupported + } + if len(labels) > 0 { + body["labels"] = labels + } + + if err := s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/revisions/current/review", nil, body, nil); err != nil { + return nil, err + } + return &forge.Review{ + State: state, + Body: opts.Body, + HTMLURL: (&gerritPRService{forge: s.forge}).changeURL(projectName(owner, repo), number), + SubmittedAt: time.Now().UTC(), + }, nil +} + +func (s *gerritReviewService) RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + for _, user := range users { + body := map[string]string{"reviewer": user} + if err := s.forge.doJSON(ctx, http.MethodPost, "/changes/"+encodeID(strconv.Itoa(number))+"/reviewers", nil, body, nil); err != nil { + return err + } + } + return nil +} + +func (s *gerritReviewService) RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + for _, user := range users { + if err := s.forge.doJSON(ctx, http.MethodDelete, "/changes/"+encodeID(strconv.Itoa(number))+"/reviewers/"+encodeID(user), nil, nil, nil); err != nil { + return err + } + } + return nil +} diff --git a/gerrit/stubs.go b/gerrit/stubs.go new file mode 100644 index 0000000..5bef12d --- /dev/null +++ b/gerrit/stubs.go @@ -0,0 +1,208 @@ +package gerrit + +import ( + "context" + "io" + "os" + + forge "github.com/git-pkgs/forge" +) + +type unsupportedIssueService struct{} +type unsupportedLabelService struct{} +type unsupportedMilestoneService struct{} +type unsupportedReleaseService struct{} +type unsupportedCIService struct{} +type unsupportedDeployKeyService struct{} +type unsupportedSecretService struct{} +type unsupportedNotificationService struct{} +type unsupportedCollaboratorService struct{} +type unsupportedCommitStatusService struct{} + +func (f *gerritForge) Issues() forge.IssueService { return &unsupportedIssueService{} } +func (f *gerritForge) Labels() forge.LabelService { return &unsupportedLabelService{} } +func (f *gerritForge) Milestones() forge.MilestoneService { return &unsupportedMilestoneService{} } +func (f *gerritForge) Releases() forge.ReleaseService { return &unsupportedReleaseService{} } +func (f *gerritForge) CI() forge.CIService { return &unsupportedCIService{} } +func (f *gerritForge) DeployKeys() forge.DeployKeyService { return &unsupportedDeployKeyService{} } +func (f *gerritForge) Secrets() forge.SecretService { return &unsupportedSecretService{} } +func (f *gerritForge) Notifications() forge.NotificationService { + return &unsupportedNotificationService{} +} +func (f *gerritForge) Collaborators() forge.CollaboratorService { + return &unsupportedCollaboratorService{} +} +func (f *gerritForge) CommitStatuses() forge.CommitStatusService { + return &unsupportedCommitStatusService{} +} +func (f *gerritForge) GetRateLimit(context.Context) (*forge.RateLimit, error) { + return nil, forge.ErrNotSupported +} + +func (s *unsupportedIssueService) Get(context.Context, string, string, int) (*forge.Issue, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) List(context.Context, string, string, forge.ListIssueOpts) ([]forge.Issue, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) Create(context.Context, string, string, forge.CreateIssueOpts) (*forge.Issue, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) Update(context.Context, string, string, int, forge.UpdateIssueOpts) (*forge.Issue, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) Close(context.Context, string, string, int) error { + return forge.ErrNotSupported +} +func (s *unsupportedIssueService) Reopen(context.Context, string, string, int) error { + return forge.ErrNotSupported +} +func (s *unsupportedIssueService) Delete(context.Context, string, string, int) error { + return forge.ErrNotSupported +} +func (s *unsupportedIssueService) CreateComment(context.Context, string, string, int, string) (*forge.Comment, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) ListComments(context.Context, string, string, int) ([]forge.Comment, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) ListReactions(context.Context, string, string, int, int64) ([]forge.Reaction, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) AddReaction(context.Context, string, string, int, int64, string) (*forge.Reaction, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedIssueService) ListURL(repoHTMLURL string) string { return repoHTMLURL } + +func (s *unsupportedLabelService) List(context.Context, string, string, forge.ListLabelOpts) ([]forge.Label, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedLabelService) Get(context.Context, string, string, string) (*forge.Label, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedLabelService) Create(context.Context, string, string, forge.CreateLabelOpts) (*forge.Label, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedLabelService) Update(context.Context, string, string, string, forge.UpdateLabelOpts) (*forge.Label, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedLabelService) Delete(context.Context, string, string, string) error { + return forge.ErrNotSupported +} +func (s *unsupportedLabelService) ListURL(repoHTMLURL string) string { return repoHTMLURL } + +func (s *unsupportedMilestoneService) List(context.Context, string, string, forge.ListMilestoneOpts) ([]forge.Milestone, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Get(context.Context, string, string, int) (*forge.Milestone, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Create(context.Context, string, string, forge.CreateMilestoneOpts) (*forge.Milestone, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Update(context.Context, string, string, int, forge.UpdateMilestoneOpts) (*forge.Milestone, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Close(context.Context, string, string, int) error { + return forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Reopen(context.Context, string, string, int) error { + return forge.ErrNotSupported +} +func (s *unsupportedMilestoneService) Delete(context.Context, string, string, int) error { + return forge.ErrNotSupported +} + +func (s *unsupportedReleaseService) List(context.Context, string, string, forge.ListReleaseOpts) ([]forge.Release, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) Get(context.Context, string, string, string) (*forge.Release, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) GetLatest(context.Context, string, string) (*forge.Release, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) Create(context.Context, string, string, forge.CreateReleaseOpts) (*forge.Release, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) Update(context.Context, string, string, string, forge.UpdateReleaseOpts) (*forge.Release, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) Delete(context.Context, string, string, string) error { + return forge.ErrNotSupported +} +func (s *unsupportedReleaseService) UploadAsset(context.Context, string, string, string, *os.File) (*forge.ReleaseAsset, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedReleaseService) DownloadAsset(context.Context, string, string, int64) (io.ReadCloser, error) { + return nil, forge.ErrNotSupported +} + +func (s *unsupportedCIService) ListRuns(context.Context, string, string, forge.ListCIRunOpts) ([]forge.CIRun, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedCIService) GetRun(context.Context, string, string, int64) (*forge.CIRun, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedCIService) TriggerRun(context.Context, string, string, forge.TriggerCIRunOpts) error { + return forge.ErrNotSupported +} +func (s *unsupportedCIService) CancelRun(context.Context, string, string, int64) error { + return forge.ErrNotSupported +} +func (s *unsupportedCIService) RetryRun(context.Context, string, string, int64) error { + return forge.ErrNotSupported +} +func (s *unsupportedCIService) GetJobLog(context.Context, string, string, int64) (io.ReadCloser, error) { + return nil, forge.ErrNotSupported +} + +func (s *unsupportedDeployKeyService) List(context.Context, string, string, forge.ListDeployKeyOpts) ([]forge.DeployKey, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedDeployKeyService) Get(context.Context, string, string, int64) (*forge.DeployKey, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedDeployKeyService) Create(context.Context, string, string, forge.CreateDeployKeyOpts) (*forge.DeployKey, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedDeployKeyService) Delete(context.Context, string, string, int64) error { + return forge.ErrNotSupported +} + +func (s *unsupportedSecretService) List(context.Context, string, string, forge.ListSecretOpts) ([]forge.Secret, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedSecretService) Set(context.Context, string, string, forge.SetSecretOpts) error { + return forge.ErrNotSupported +} +func (s *unsupportedSecretService) Delete(context.Context, string, string, string) error { + return forge.ErrNotSupported +} + +func (s *unsupportedNotificationService) List(context.Context, forge.ListNotificationOpts) ([]forge.Notification, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedNotificationService) MarkRead(context.Context, forge.MarkNotificationOpts) error { + return forge.ErrNotSupported +} +func (s *unsupportedNotificationService) Get(context.Context, string) (*forge.Notification, error) { + return nil, forge.ErrNotSupported +} + +func (s *unsupportedCollaboratorService) List(context.Context, string, string, forge.ListCollaboratorOpts) ([]forge.Collaborator, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedCollaboratorService) Add(context.Context, string, string, string, forge.AddCollaboratorOpts) error { + return forge.ErrNotSupported +} +func (s *unsupportedCollaboratorService) Remove(context.Context, string, string, string) error { + return forge.ErrNotSupported +} + +func (s *unsupportedCommitStatusService) List(context.Context, string, string, string) ([]forge.CommitStatus, error) { + return nil, forge.ErrNotSupported +} +func (s *unsupportedCommitStatusService) Set(context.Context, string, string, string, forge.SetCommitStatusOpts) (*forge.CommitStatus, error) { + return nil, forge.ErrNotSupported +} diff --git a/gerrit/url.go b/gerrit/url.go new file mode 100644 index 0000000..005f174 --- /dev/null +++ b/gerrit/url.go @@ -0,0 +1,52 @@ +package gerrit + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + forge "github.com/git-pkgs/forge" +) + +// ParsePath implements Forge.ParsePath for Gerrit URLs. +func (f *gerritForge) ParsePath(parts []string) (*forge.ResourceRef, error) { + if len(parts) == 0 { + return nil, fmt.Errorf("URL path must contain a Gerrit resource") + } + + if parts[0] == "c" { + for i, part := range parts { + if part != "+" || i+1 >= len(parts) { + continue + } + number, err := strconv.Atoi(parts[i+1]) + if err != nil { + return nil, fmt.Errorf("invalid change number %q", parts[i+1]) + } + project := strings.Join(parts[1:i], "/") + decoded, err := url.PathUnescape(project) + if err != nil { + return nil, err + } + owner, repo := splitProjectName(decoded) + return &forge.ResourceRef{ + Owner: owner, + Repo: repo, + Type: forge.ResourceTypePR, + Number: number, + }, nil + } + } + + if len(parts) >= 3 && parts[0] == "admin" && parts[1] == "repos" { + project, err := url.PathUnescape(parts[2]) + if err != nil { + return nil, err + } + owner, repo := splitProjectName(project) + return &forge.ResourceRef{Owner: owner, Repo: repo}, nil + } + + return nil, fmt.Errorf("unsupported Gerrit URL path") +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0d3b78f..ae3b3d0 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -76,7 +76,7 @@ func authLoginCmd() *cobra.Command { cmd.Flags().StringVar(&domain, "domain", "", "Forge domain (e.g. github.com, gitea.example.com)") cmd.Flags().StringVar(&token, "token", "", "API token") - cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket") + cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit") return cmd } @@ -91,7 +91,7 @@ func authStatusCmd() *cobra.Command { } // Known domains to check for env var tokens - knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org"} + knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "gerrit-review.googlesource.com"} // Collect all unique domains domains := make(map[string]bool) diff --git a/internal/cli/repo_test.go b/internal/cli/repo_test.go index 3babd20..5ac6099 100644 --- a/internal/cli/repo_test.go +++ b/internal/cli/repo_test.go @@ -97,6 +97,7 @@ func TestDomainFromFlags(t *testing.T) { {"gitea", "codeberg.org"}, {"forgejo", "codeberg.org"}, {"bitbucket", "bitbucket.org"}, + {"gerrit", "gerrit-review.googlesource.com"}, } for _, tt := range tests { diff --git a/internal/cli/root.go b/internal/cli/root.go index 431be8e..0294a4e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,7 +23,7 @@ var ( var rootCmd = &cobra.Command{ Use: "forge", Short: "Work with git forges from the command line", - Long: "Supports GitHub, GitLab, Gitea, Forgejo, and Bitbucket Cloud through a single interface.", + Long: "Supports GitHub, GitLab, Gitea, Forgejo, Bitbucket Cloud, and Gerrit through a single interface.", SilenceUsage: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { if !cmd.Flags().Changed("output") { @@ -45,7 +45,7 @@ func Execute() error { func init() { rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)") - rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket") + rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit") rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default origin)") diff --git a/internal/config/config.go b/internal/config/config.go index d7b9085..7ca8eef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,7 +29,7 @@ type DefaultSection struct { } type DomainSection struct { - Type string // github, gitlab, gitea, forgejo, bitbucket + Type string // github, gitlab, gitea, forgejo, bitbucket, gerrit Token string // only from user config, never .forge SSHHost string // alternate host for git-over-ssh; the section name remains the API host GitProtocol string // https or ssh; overrides default diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 93c7191..c06f5a9 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -11,6 +11,7 @@ import ( "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/bitbucket" + "github.com/git-pkgs/forge/gerrit" "github.com/git-pkgs/forge/gitea" ghforge "github.com/git-pkgs/forge/github" glforge "github.com/git-pkgs/forge/gitlab" @@ -85,6 +86,7 @@ var builders = forges.ForgeBuilders{ GitHub: ghforge.NewWithBase, GitLab: glforge.New, Gitea: gitea.New, + Gerrit: gerrit.New, } // Repo figures out the forge, owner, and repo name from flags or the current @@ -251,6 +253,8 @@ func forgeForType(forgeType, baseURL, token string, hc *http.Client) forges.Forg return glforge.New(baseURL, token, hc) case "github": return ghforge.NewWithBase(baseURL, token, hc) + case "gerrit": + return gerrit.New(baseURL, token, hc) } return nil } @@ -323,6 +327,10 @@ func TokenForDomainEnv(domain string) string { if t := os.Getenv("BITBUCKET_TOKEN"); t != "" { return t } + case "gerrit-review.googlesource.com": + if t := os.Getenv("GERRIT_TOKEN"); t != "" { + return t + } } // FORGE_TOKEN is a fallback for any domain without a specific token. @@ -367,6 +375,8 @@ func defaultDomainForType(forgeType string) string { return "codeberg.org" case "bitbucket": return "bitbucket.org" + case "gerrit": + return "gerrit-review.googlesource.com" default: return "github.com" } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 24ad32b..8a7b8b9 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -60,7 +60,7 @@ func clearTokenEnv(t *testing.T) { for _, v := range []string{ "GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", - "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", + "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "GERRIT_TOKEN", "FORGE_TOKEN", } { t.Setenv(v, "") @@ -129,6 +129,14 @@ func TestTokenForDomain(t *testing.T) { } t.Setenv("FORGEJO_TOKEN", "") t.Setenv("GITEA_TOKEN", "") + + // Gerrit + t.Setenv("GERRIT_TOKEN", "gerrit-tok") + got = TokenForDomain("gerrit-review.googlesource.com") + if got != "gerrit-tok" { + t.Errorf("expected gerrit-tok, got %q", got) + } + t.Setenv("GERRIT_TOKEN", "") } func TestTokenForDomainEnvSpecificOverridesFallback(t *testing.T) { @@ -175,6 +183,7 @@ func TestDomain(t *testing.T) { {"gitea", "codeberg.org"}, {"forgejo", "codeberg.org"}, {"bitbucket", "bitbucket.org"}, + {"gerrit", "gerrit-review.googlesource.com"}, {"unknown", "github.com"}, } diff --git a/types.go b/types.go index 8e55e19..5b83703 100644 --- a/types.go +++ b/types.go @@ -11,6 +11,7 @@ const ( Gitea ForgeType = "gitea" Forgejo ForgeType = "forgejo" Bitbucket ForgeType = "bitbucket" + Gerrit ForgeType = "gerrit" Unknown ForgeType = "unknown" )