Skip to content
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# forge

Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, and Tangled through a single interface.
Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, and Tangled through a single interface.

## CLI

Expand Down Expand Up @@ -42,6 +42,7 @@ 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
forge auth login --domain github.com --token-cmd 'rbw get github-token'
```

Expand Down Expand Up @@ -76,6 +77,10 @@ token = ghp_abc123
[gitea.example.com]
type = gitea
token = abc123

[gerrit.example.com]
type = gerrit
token = user:http_password
```

### Token commands
Expand Down
33 changes: 33 additions & 0 deletions detect.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package forges

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +117,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 {
Expand Down
6 changes: 6 additions & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ 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:
if builders.Gerrit == nil {
return fmt.Errorf("no builder registered for forge type %q at %s", ft, domain)
}
c.forges[domain] = builders.Gerrit(baseURL, token, c.httpClient)
case Tangled:
if builders.Tangled == nil {
return fmt.Errorf("no builder registered for forge type %q at %s", ft, domain)
Expand All @@ -164,6 +169,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
Tangled func(baseURL, token string, hc *http.Client) Forge
}

Expand Down
65 changes: 65 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,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 TestDetectForgeTypeTangledAPI(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -354,6 +378,47 @@ func TestDetectForgeTypeTangledAPI(t *testing.T) {
}
}

func TestRegisterDomainGerritMissingBuilderReturnsError(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()

base, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
client := NewClient(WithHTTPClient(&http.Client{
Transport: rewriteHostTransport{base: base},
}))

err = client.RegisterDomain(context.Background(), "gerrit.example", "", ForgeBuilders{
GitHub: func(baseURL, token string, hc *http.Client) Forge {
return &mockForge{}
},
GitLab: func(baseURL, token string, hc *http.Client) Forge {
return &mockForge{}
},
Gitea: func(baseURL, token string, hc *http.Client) Forge {
return &mockForge{}
},
})
if err == nil {
t.Fatal("expected missing Gerrit builder error")
}
if !strings.Contains(err.Error(), "no builder registered") || !strings.Contains(err.Error(), "gerrit") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRegisterDomainTangledMissingBuilderReturnsError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
Expand Down
63 changes: 63 additions & 0 deletions gerrit/branches.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading