Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions 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, 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

Expand Down Expand Up @@ -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

Expand All @@ -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:
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 @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
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
Loading