diff --git a/CLAUDE.md b/CLAUDE.md index 1c1fe29..ad09a8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,9 @@ The library is a flat package (`package forge`) with a layered design: 5. **`config.go`** — Config resolution: flags > env (`FORGE_URL`, `FORGE_TOKEN`) > defaults (`http://localhost:3000`). -6. **`forge.go`** — Top-level `Forge` struct aggregating all 18 service fields. Created via `NewForge(url, token)` or `NewForgeFromConfig(flagURL, flagToken)`. +6. **`forge.go`** — Top-level `Forge` struct aggregating all 20 service fields. Created via `NewForge(url, token)` or `NewForgeFromConfig(flagURL, flagToken)`. -7. **Service files** (`repos.go`, `issues.go`, etc.) — Each service struct embeds `Resource[T,C,U]` for standard CRUD, then adds hand-written action methods (e.g. `Fork`, `Pin`, `MirrorSync`). 18 services total: repos, issues, pulls, orgs, users, teams, admin, branches, releases, labels, webhooks, notifications, packages, actions, contents, wiki, commits, misc. +7. **Service files** (`repos.go`, `issues.go`, etc.) — Each service struct embeds `Resource[T,C,U]` for standard CRUD, then adds hand-written action methods (e.g. `Fork`, `Pin`, `MirrorSync`). 20 services total: repos, issues, pulls, orgs, users, teams, admin, branches, releases, labels, webhooks, notifications, packages, actions, contents, wiki, commits, milestones, misc, activitypub. 8. **`types/`** — Generated Go types from `swagger.v1.json` (229 types). The `//go:generate` directive lives in `types/generate.go`. **Do not hand-edit generated type files** — modify `cmd/forgegen/` instead. diff --git a/actions.go b/actions.go index ba110b8..bb26a99 100644 --- a/actions.go +++ b/actions.go @@ -2,15 +2,22 @@ package forge import ( "context" - "fmt" "iter" + "net/url" + "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // ActionsService handles CI/CD actions operations across repositories and -// organisations — secrets, variables, and workflow dispatches. +// organisations — secrets, variables, workflow dispatches, and tasks. // No Resource embedding — heterogeneous endpoints across repo and org levels. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Actions.ListRepoSecrets(ctx, "core", "go-forge") type ActionsService struct { client *Client } @@ -21,82 +28,239 @@ func newActionsService(c *Client) *ActionsService { // ListRepoSecrets returns all secrets for a repository. func (s *ActionsService) ListRepoSecrets(ctx context.Context, owner, repo string) ([]types.Secret, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets", pathParams("owner", owner, "repo", repo)) return ListAll[types.Secret](ctx, s.client, path, nil) } // IterRepoSecrets returns an iterator over all secrets for a repository. func (s *ActionsService) IterRepoSecrets(ctx context.Context, owner, repo string) iter.Seq2[types.Secret, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets", pathParams("owner", owner, "repo", repo)) return ListIter[types.Secret](ctx, s.client, path, nil) } // CreateRepoSecret creates or updates a secret in a repository. // Forgejo expects a PUT with {"data": "secret-value"} body. func (s *ActionsService) CreateRepoSecret(ctx context.Context, owner, repo, name string, data string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}", pathParams("owner", owner, "repo", repo, "secretname", name)) body := map[string]string{"data": data} return s.client.Put(ctx, path, body, nil) } // DeleteRepoSecret removes a secret from a repository. func (s *ActionsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}", pathParams("owner", owner, "repo", repo, "secretname", name)) return s.client.Delete(ctx, path) } // ListRepoVariables returns all action variables for a repository. func (s *ActionsService) ListRepoVariables(ctx context.Context, owner, repo string) ([]types.ActionVariable, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables", pathParams("owner", owner, "repo", repo)) return ListAll[types.ActionVariable](ctx, s.client, path, nil) } // IterRepoVariables returns an iterator over all action variables for a repository. func (s *ActionsService) IterRepoVariables(ctx context.Context, owner, repo string) iter.Seq2[types.ActionVariable, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables", pathParams("owner", owner, "repo", repo)) return ListIter[types.ActionVariable](ctx, s.client, path, nil) } // CreateRepoVariable creates a new action variable in a repository. // Forgejo expects a POST with {"value": "var-value"} body. func (s *ActionsService) CreateRepoVariable(ctx context.Context, owner, repo, name, value string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{variablename}", pathParams("owner", owner, "repo", repo, "variablename", name)) body := types.CreateVariableOption{Value: value} return s.client.Post(ctx, path, body, nil) } +// UpdateRepoVariable updates an existing action variable in a repository. +func (s *ActionsService) UpdateRepoVariable(ctx context.Context, owner, repo, name string, opts *types.UpdateVariableOption) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{variablename}", pathParams("owner", owner, "repo", repo, "variablename", name)) + return s.client.Put(ctx, path, opts, nil) +} + // DeleteRepoVariable removes an action variable from a repository. func (s *ActionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{variablename}", pathParams("owner", owner, "repo", repo, "variablename", name)) return s.client.Delete(ctx, path) } // ListOrgSecrets returns all secrets for an organisation. func (s *ActionsService) ListOrgSecrets(ctx context.Context, org string) ([]types.Secret, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/secrets", org) + path := ResolvePath("/api/v1/orgs/{org}/actions/secrets", pathParams("org", org)) return ListAll[types.Secret](ctx, s.client, path, nil) } // IterOrgSecrets returns an iterator over all secrets for an organisation. func (s *ActionsService) IterOrgSecrets(ctx context.Context, org string) iter.Seq2[types.Secret, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/secrets", org) + path := ResolvePath("/api/v1/orgs/{org}/actions/secrets", pathParams("org", org)) return ListIter[types.Secret](ctx, s.client, path, nil) } // ListOrgVariables returns all action variables for an organisation. func (s *ActionsService) ListOrgVariables(ctx context.Context, org string) ([]types.ActionVariable, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/variables", org) + path := ResolvePath("/api/v1/orgs/{org}/actions/variables", pathParams("org", org)) return ListAll[types.ActionVariable](ctx, s.client, path, nil) } // IterOrgVariables returns an iterator over all action variables for an organisation. func (s *ActionsService) IterOrgVariables(ctx context.Context, org string) iter.Seq2[types.ActionVariable, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/actions/variables", org) + path := ResolvePath("/api/v1/orgs/{org}/actions/variables", pathParams("org", org)) return ListIter[types.ActionVariable](ctx, s.client, path, nil) } +// GetOrgVariable returns a single action variable for an organisation. +func (s *ActionsService) GetOrgVariable(ctx context.Context, org, name string) (*types.ActionVariable, error) { + path := ResolvePath("/api/v1/orgs/{org}/actions/variables/{variablename}", pathParams("org", org, "variablename", name)) + var out types.ActionVariable + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgVariable creates a new action variable in an organisation. +func (s *ActionsService) CreateOrgVariable(ctx context.Context, org, name, value string) error { + path := ResolvePath("/api/v1/orgs/{org}/actions/variables/{variablename}", pathParams("org", org, "variablename", name)) + body := types.CreateVariableOption{Value: value} + return s.client.Post(ctx, path, body, nil) +} + +// UpdateOrgVariable updates an existing action variable in an organisation. +func (s *ActionsService) UpdateOrgVariable(ctx context.Context, org, name string, opts *types.UpdateVariableOption) error { + path := ResolvePath("/api/v1/orgs/{org}/actions/variables/{variablename}", pathParams("org", org, "variablename", name)) + return s.client.Put(ctx, path, opts, nil) +} + +// DeleteOrgVariable removes an action variable from an organisation. +func (s *ActionsService) DeleteOrgVariable(ctx context.Context, org, name string) error { + path := ResolvePath("/api/v1/orgs/{org}/actions/variables/{variablename}", pathParams("org", org, "variablename", name)) + return s.client.Delete(ctx, path) +} + +// CreateOrgSecret creates or updates a secret in an organisation. +func (s *ActionsService) CreateOrgSecret(ctx context.Context, org, name, data string) error { + path := ResolvePath("/api/v1/orgs/{org}/actions/secrets/{secretname}", pathParams("org", org, "secretname", name)) + body := map[string]string{"data": data} + return s.client.Put(ctx, path, body, nil) +} + +// DeleteOrgSecret removes a secret from an organisation. +func (s *ActionsService) DeleteOrgSecret(ctx context.Context, org, name string) error { + path := ResolvePath("/api/v1/orgs/{org}/actions/secrets/{secretname}", pathParams("org", org, "secretname", name)) + return s.client.Delete(ctx, path) +} + +// ListUserVariables returns all action variables for the authenticated user. +func (s *ActionsService) ListUserVariables(ctx context.Context) ([]types.ActionVariable, error) { + return ListAll[types.ActionVariable](ctx, s.client, "/api/v1/user/actions/variables", nil) +} + +// IterUserVariables returns an iterator over all action variables for the authenticated user. +func (s *ActionsService) IterUserVariables(ctx context.Context) iter.Seq2[types.ActionVariable, error] { + return ListIter[types.ActionVariable](ctx, s.client, "/api/v1/user/actions/variables", nil) +} + +// GetUserVariable returns a single action variable for the authenticated user. +func (s *ActionsService) GetUserVariable(ctx context.Context, name string) (*types.ActionVariable, error) { + path := ResolvePath("/api/v1/user/actions/variables/{variablename}", pathParams("variablename", name)) + var out types.ActionVariable + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateUserVariable creates a new action variable for the authenticated user. +func (s *ActionsService) CreateUserVariable(ctx context.Context, name, value string) error { + path := ResolvePath("/api/v1/user/actions/variables/{variablename}", pathParams("variablename", name)) + body := types.CreateVariableOption{Value: value} + return s.client.Post(ctx, path, body, nil) +} + +// UpdateUserVariable updates an existing action variable for the authenticated user. +func (s *ActionsService) UpdateUserVariable(ctx context.Context, name string, opts *types.UpdateVariableOption) error { + path := ResolvePath("/api/v1/user/actions/variables/{variablename}", pathParams("variablename", name)) + return s.client.Put(ctx, path, opts, nil) +} + +// DeleteUserVariable removes an action variable for the authenticated user. +func (s *ActionsService) DeleteUserVariable(ctx context.Context, name string) error { + path := ResolvePath("/api/v1/user/actions/variables/{variablename}", pathParams("variablename", name)) + return s.client.Delete(ctx, path) +} + +// CreateUserSecret creates or updates a secret for the authenticated user. +func (s *ActionsService) CreateUserSecret(ctx context.Context, name, data string) error { + path := ResolvePath("/api/v1/user/actions/secrets/{secretname}", pathParams("secretname", name)) + body := map[string]string{"data": data} + return s.client.Put(ctx, path, body, nil) +} + +// DeleteUserSecret removes a secret for the authenticated user. +func (s *ActionsService) DeleteUserSecret(ctx context.Context, name string) error { + path := ResolvePath("/api/v1/user/actions/secrets/{secretname}", pathParams("secretname", name)) + return s.client.Delete(ctx, path) +} + // DispatchWorkflow triggers a workflow run. func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches", pathParams("owner", owner, "repo", repo, "workflowname", workflow)) return s.client.Post(ctx, path, opts, nil) } + +// ListRepoTasks returns a single page of action tasks for a repository. +func (s *ActionsService) ListRepoTasks(ctx context.Context, owner, repo string, opts ListOptions) (*types.ActionTaskResponse, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/tasks", pathParams("owner", owner, "repo", repo)) + + if opts.Page > 0 || opts.Limit > 0 { + u, err := url.Parse(path) + if err != nil { + return nil, core.E("ActionsService.ListRepoTasks", "forge: parse path", err) + } + q := u.Query() + if opts.Page > 0 { + q.Set("page", strconv.Itoa(opts.Page)) + } + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + u.RawQuery = q.Encode() + path = u.String() + } + + var out types.ActionTaskResponse + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// IterRepoTasks returns an iterator over all action tasks for a repository. +func (s *ActionsService) IterRepoTasks(ctx context.Context, owner, repo string) iter.Seq2[types.ActionTask, error] { + return func(yield func(types.ActionTask, error) bool) { + const limit = 50 + var seen int64 + for page := 1; ; page++ { + resp, err := s.ListRepoTasks(ctx, owner, repo, ListOptions{Page: page, Limit: limit}) + if err != nil { + yield(*new(types.ActionTask), err) + return + } + for _, item := range resp.Entries { + if !yield(*item, nil) { + return + } + seen++ + } + if resp.TotalCount > 0 { + if seen >= resp.TotalCount { + return + } + continue + } + if len(resp.Entries) < limit { + return + } + } + } +} diff --git a/actions_test.go b/actions_test.go index 9304440..755ce7b 100644 --- a/actions_test.go +++ b/actions_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestActionsService_Good_ListRepoSecrets(t *testing.T) { +func TestActionsService_ListRepoSecrets_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -42,7 +42,7 @@ func TestActionsService_Good_ListRepoSecrets(t *testing.T) { } } -func TestActionsService_Good_CreateRepoSecret(t *testing.T) { +func TestActionsService_CreateRepoSecret_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Errorf("expected PUT, got %s", r.Method) @@ -68,7 +68,7 @@ func TestActionsService_Good_CreateRepoSecret(t *testing.T) { } } -func TestActionsService_Good_DeleteRepoSecret(t *testing.T) { +func TestActionsService_DeleteRepoSecret_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -87,7 +87,7 @@ func TestActionsService_Good_DeleteRepoSecret(t *testing.T) { } } -func TestActionsService_Good_ListRepoVariables(t *testing.T) { +func TestActionsService_ListRepoVariables_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -115,7 +115,7 @@ func TestActionsService_Good_ListRepoVariables(t *testing.T) { } } -func TestActionsService_Good_CreateRepoVariable(t *testing.T) { +func TestActionsService_CreateRepoVariable_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -141,7 +141,39 @@ func TestActionsService_Good_CreateRepoVariable(t *testing.T) { } } -func TestActionsService_Good_DeleteRepoVariable(t *testing.T) { +func TestActionsService_UpdateRepoVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/variables/CI_ENV" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.UpdateVariableOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "CI_ENV_NEW" { + t.Errorf("got name=%q, want %q", body.Name, "CI_ENV_NEW") + } + if body.Value != "production" { + t.Errorf("got value=%q, want %q", body.Value, "production") + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.UpdateRepoVariable(context.Background(), "core", "go-forge", "CI_ENV", &types.UpdateVariableOption{ + Name: "CI_ENV_NEW", + Value: "production", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_DeleteRepoVariable_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -160,7 +192,7 @@ func TestActionsService_Good_DeleteRepoVariable(t *testing.T) { } } -func TestActionsService_Good_ListOrgSecrets(t *testing.T) { +func TestActionsService_ListOrgSecrets_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -188,7 +220,7 @@ func TestActionsService_Good_ListOrgSecrets(t *testing.T) { } } -func TestActionsService_Good_ListOrgVariables(t *testing.T) { +func TestActionsService_ListOrgVariables_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -216,7 +248,188 @@ func TestActionsService_Good_ListOrgVariables(t *testing.T) { } } -func TestActionsService_Good_DispatchWorkflow(t *testing.T) { +func TestActionsService_GetOrgVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/lethean/actions/variables/ORG_VAR" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.ActionVariable{Name: "ORG_VAR", Data: "org-value"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + variable, err := f.Actions.GetOrgVariable(context.Background(), "lethean", "ORG_VAR") + if err != nil { + t.Fatal(err) + } + if variable.Name != "ORG_VAR" || variable.Data != "org-value" { + t.Fatalf("unexpected variable: %#v", variable) + } +} + +func TestActionsService_CreateUserVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/variables/CI_ENV" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateVariableOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Value != "production" { + t.Errorf("got value=%q, want %q", body.Value, "production") + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Actions.CreateUserVariable(context.Background(), "CI_ENV", "production"); err != nil { + t.Fatal(err) + } +} + +func TestActionsService_ListUserVariables_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/variables" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.ActionVariable{{Name: "CI_ENV", Data: "production"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + vars, err := f.Actions.ListUserVariables(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(vars) != 1 || vars[0].Name != "CI_ENV" { + t.Fatalf("unexpected variables: %#v", vars) + } +} + +func TestActionsService_GetUserVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/variables/CI_ENV" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.ActionVariable{Name: "CI_ENV", Data: "production"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + variable, err := f.Actions.GetUserVariable(context.Background(), "CI_ENV") + if err != nil { + t.Fatal(err) + } + if variable.Name != "CI_ENV" || variable.Data != "production" { + t.Fatalf("unexpected variable: %#v", variable) + } +} + +func TestActionsService_UpdateUserVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/variables/CI_ENV" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.UpdateVariableOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "CI_ENV_NEW" || body.Value != "staging" { + t.Fatalf("unexpected body: %#v", body) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Actions.UpdateUserVariable(context.Background(), "CI_ENV", &types.UpdateVariableOption{ + Name: "CI_ENV_NEW", + Value: "staging", + }); err != nil { + t.Fatal(err) + } +} + +func TestActionsService_DeleteUserVariable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/variables/OLD_VAR" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Actions.DeleteUserVariable(context.Background(), "OLD_VAR"); err != nil { + t.Fatal(err) + } +} + +func TestActionsService_CreateUserSecret_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/secrets/DEPLOY_KEY" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["data"] != "secret-value" { + t.Errorf("got data=%q, want %q", body["data"], "secret-value") + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Actions.CreateUserSecret(context.Background(), "DEPLOY_KEY", "secret-value"); err != nil { + t.Fatal(err) + } +} + +func TestActionsService_DeleteUserSecret_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/actions/secrets/OLD_KEY" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Actions.DeleteUserSecret(context.Background(), "OLD_KEY"); err != nil { + t.Fatal(err) + } +} + +func TestActionsService_DispatchWorkflow_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -244,7 +457,88 @@ func TestActionsService_Good_DispatchWorkflow(t *testing.T) { } } -func TestActionsService_Bad_NotFound(t *testing.T) { +func TestActionsService_ListRepoTasks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/tasks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{ + {ID: 101, Name: "build"}, + {ID: 102, Name: "test"}, + }, + TotalCount: 2, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Actions.ListRepoTasks(context.Background(), "core", "go-forge", ListOptions{Page: 2, Limit: 25}) + if err != nil { + t.Fatal(err) + } + if resp.TotalCount != 2 { + t.Fatalf("got total_count=%d, want 2", resp.TotalCount) + } + if len(resp.Entries) != 2 { + t.Fatalf("got %d tasks, want 2", len(resp.Entries)) + } + if resp.Entries[0].ID != 101 || resp.Entries[1].Name != "test" { + t.Fatalf("unexpected tasks: %#v", resp.Entries) + } +} + +func TestActionsService_IterRepoTasks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/tasks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + switch r.URL.Query().Get("page") { + case "1": + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{{ID: 1, Name: "build"}}, + TotalCount: 2, + }) + case "2": + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{{ID: 2, Name: "test"}}, + TotalCount: 2, + }) + default: + t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.ActionTask + for task, err := range f.Actions.IterRepoTasks(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, task) + } + if len(got) != 2 { + t.Fatalf("got %d tasks, want 2", len(got)) + } + if got[0].ID != 1 || got[1].Name != "test" { + t.Fatalf("unexpected tasks: %#v", got) + } +} + +func TestActionsService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) diff --git a/activitypub.go b/activitypub.go new file mode 100644 index 0000000..e3141cb --- /dev/null +++ b/activitypub.go @@ -0,0 +1,67 @@ +package forge + +import ( + "context" + + "dappco.re/go/core/forge/types" +) + +// ActivityPubService handles ActivityPub actor and inbox endpoints. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.ActivityPub.GetInstanceActor(ctx) +type ActivityPubService struct { + client *Client +} + +func newActivityPubService(c *Client) *ActivityPubService { + return &ActivityPubService{client: c} +} + +// GetInstanceActor returns the instance's ActivityPub actor. +func (s *ActivityPubService) GetInstanceActor(ctx context.Context) (*types.ActivityPub, error) { + var out types.ActivityPub + if err := s.client.Get(ctx, "/activitypub/actor", &out); err != nil { + return nil, err + } + return &out, nil +} + +// SendInstanceActorInbox sends an ActivityPub object to the instance inbox. +func (s *ActivityPubService) SendInstanceActorInbox(ctx context.Context, body *types.ForgeLike) error { + return s.client.Post(ctx, "/activitypub/actor/inbox", body, nil) +} + +// GetRepositoryActor returns the ActivityPub actor for a repository. +func (s *ActivityPubService) GetRepositoryActor(ctx context.Context, repositoryID int64) (*types.ActivityPub, error) { + path := ResolvePath("/activitypub/repository-id/{repository-id}", Params{"repository-id": int64String(repositoryID)}) + var out types.ActivityPub + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SendRepositoryInbox sends an ActivityPub object to a repository inbox. +func (s *ActivityPubService) SendRepositoryInbox(ctx context.Context, repositoryID int64, body *types.ForgeLike) error { + path := ResolvePath("/activitypub/repository-id/{repository-id}/inbox", Params{"repository-id": int64String(repositoryID)}) + return s.client.Post(ctx, path, body, nil) +} + +// GetPersonActor returns the Person actor for a user. +func (s *ActivityPubService) GetPersonActor(ctx context.Context, userID int64) (*types.ActivityPub, error) { + path := ResolvePath("/activitypub/user-id/{user-id}", Params{"user-id": int64String(userID)}) + var out types.ActivityPub + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SendPersonInbox sends an ActivityPub object to a user's inbox. +func (s *ActivityPubService) SendPersonInbox(ctx context.Context, userID int64, body *types.ForgeLike) error { + path := ResolvePath("/activitypub/user-id/{user-id}/inbox", Params{"user-id": int64String(userID)}) + return s.client.Post(ctx, path, body, nil) +} diff --git a/activitypub_test.go b/activitypub_test.go new file mode 100644 index 0000000..6ad2442 --- /dev/null +++ b/activitypub_test.go @@ -0,0 +1,59 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestActivityPubService_GetInstanceActor_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/activitypub/actor" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.ActivityPub{Context: "https://www.w3.org/ns/activitystreams"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + actor, err := f.ActivityPub.GetInstanceActor(context.Background()) + if err != nil { + t.Fatal(err) + } + if actor.Context != "https://www.w3.org/ns/activitystreams" { + t.Fatalf("got context=%q", actor.Context) + } +} + +func TestActivityPubService_SendRepositoryInbox_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/activitypub/repository-id/42/inbox" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.ForgeLike + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.ActivityPub.SendRepositoryInbox(context.Background(), 42, &types.ForgeLike{}); err != nil { + t.Fatal(err) + } +} diff --git a/admin.go b/admin.go index a887316..331c18f 100644 --- a/admin.go +++ b/admin.go @@ -3,17 +3,100 @@ package forge import ( "context" "iter" + "net/http" + "net/url" + "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // AdminService handles site administration operations. // Unlike other services, AdminService does not embed Resource[T,C,U] // because admin endpoints are heterogeneous. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Admin.ListUsers(ctx) type AdminService struct { client *Client } +// AdminActionsRunListOptions controls filtering for admin Actions run listings. +// +// Usage: +// +// opts := forge.AdminActionsRunListOptions{Event: "push", Status: "success"} +type AdminActionsRunListOptions struct { + Event string + Branch string + Status string + Actor string + HeadSHA string +} + +// String returns a safe summary of the admin Actions run filters. +func (o AdminActionsRunListOptions) String() string { + return optionString("forge.AdminActionsRunListOptions", + "event", o.Event, + "branch", o.Branch, + "status", o.Status, + "actor", o.Actor, + "head_sha", o.HeadSHA, + ) +} + +// GoString returns a safe Go-syntax summary of the admin Actions run filters. +func (o AdminActionsRunListOptions) GoString() string { return o.String() } + +func (o AdminActionsRunListOptions) queryParams() map[string]string { + query := make(map[string]string, 5) + if o.Event != "" { + query["event"] = o.Event + } + if o.Branch != "" { + query["branch"] = o.Branch + } + if o.Status != "" { + query["status"] = o.Status + } + if o.Actor != "" { + query["actor"] = o.Actor + } + if o.HeadSHA != "" { + query["head_sha"] = o.HeadSHA + } + if len(query) == 0 { + return nil + } + return query +} + +// AdminUnadoptedListOptions controls filtering for unadopted repository listings. +// +// Usage: +// +// opts := forge.AdminUnadoptedListOptions{Pattern: "core/*"} +type AdminUnadoptedListOptions struct { + Pattern string +} + +// String returns a safe summary of the unadopted repository filters. +func (o AdminUnadoptedListOptions) String() string { + return optionString("forge.AdminUnadoptedListOptions", "pattern", o.Pattern) +} + +// GoString returns a safe Go-syntax summary of the unadopted repository filters. +func (o AdminUnadoptedListOptions) GoString() string { return o.String() } + +func (o AdminUnadoptedListOptions) queryParams() map[string]string { + if o.Pattern == "" { + return nil + } + return map[string]string{"pattern": o.Pattern} +} + func newAdminService(c *Client) *AdminService { return &AdminService{client: c} } @@ -37,6 +120,58 @@ func (s *AdminService) CreateUser(ctx context.Context, opts *types.CreateUserOpt return &out, nil } +// CreateUserKey adds a public key on behalf of a user. +func (s *AdminService) CreateUserKey(ctx context.Context, username string, opts *types.CreateKeyOption) (*types.PublicKey, error) { + path := ResolvePath("/api/v1/admin/users/{username}/keys", Params{"username": username}) + var out types.PublicKey + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteUserKey deletes a user's public key. +func (s *AdminService) DeleteUserKey(ctx context.Context, username string, id int64) error { + path := ResolvePath("/api/v1/admin/users/{username}/keys/{id}", Params{"username": username, "id": int64String(id)}) + return s.client.Delete(ctx, path) +} + +// CreateUserOrg creates an organisation on behalf of a user. +func (s *AdminService) CreateUserOrg(ctx context.Context, username string, opts *types.CreateOrgOption) (*types.Organization, error) { + path := ResolvePath("/api/v1/admin/users/{username}/orgs", Params{"username": username}) + var out types.Organization + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetUserQuota returns a user's quota information. +func (s *AdminService) GetUserQuota(ctx context.Context, username string) (*types.QuotaInfo, error) { + path := ResolvePath("/api/v1/admin/users/{username}/quota", Params{"username": username}) + var out types.QuotaInfo + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SetUserQuotaGroups sets the user's quota groups to a given list. +func (s *AdminService) SetUserQuotaGroups(ctx context.Context, username string, opts *types.SetUserQuotaGroupsOptions) error { + path := ResolvePath("/api/v1/admin/users/{username}/quota/groups", Params{"username": username}) + return s.client.Post(ctx, path, opts, nil) +} + +// CreateUserRepo creates a repository on behalf of a user. +func (s *AdminService) CreateUserRepo(ctx context.Context, username string, opts *types.CreateRepoOption) (*types.Repository, error) { + path := ResolvePath("/api/v1/admin/users/{username}/repos", Params{"username": username}) + var out types.Repository + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // EditUser edits an existing user (admin only). func (s *AdminService) EditUser(ctx context.Context, username string, opts map[string]any) error { path := ResolvePath("/api/v1/admin/users/{username}", Params{"username": username}) @@ -65,6 +200,219 @@ func (s *AdminService) IterOrgs(ctx context.Context) iter.Seq2[types.Organizatio return ListIter[types.Organization](ctx, s.client, "/api/v1/admin/orgs", nil) } +// ListEmails returns all email addresses (admin only). +func (s *AdminService) ListEmails(ctx context.Context) ([]types.Email, error) { + return ListAll[types.Email](ctx, s.client, "/api/v1/admin/emails", nil) +} + +// IterEmails returns an iterator over all email addresses (admin only). +func (s *AdminService) IterEmails(ctx context.Context) iter.Seq2[types.Email, error] { + return ListIter[types.Email](ctx, s.client, "/api/v1/admin/emails", nil) +} + +// ListHooks returns all global hooks (admin only). +func (s *AdminService) ListHooks(ctx context.Context) ([]types.Hook, error) { + return ListAll[types.Hook](ctx, s.client, "/api/v1/admin/hooks", nil) +} + +// IterHooks returns an iterator over all global hooks (admin only). +func (s *AdminService) IterHooks(ctx context.Context) iter.Seq2[types.Hook, error] { + return ListIter[types.Hook](ctx, s.client, "/api/v1/admin/hooks", nil) +} + +// GetHook returns a single global hook by ID (admin only). +func (s *AdminService) GetHook(ctx context.Context, id int64) (*types.Hook, error) { + path := ResolvePath("/api/v1/admin/hooks/{id}", Params{"id": int64String(id)}) + var out types.Hook + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateHook creates a new global hook (admin only). +func (s *AdminService) CreateHook(ctx context.Context, opts *types.CreateHookOption) (*types.Hook, error) { + var out types.Hook + if err := s.client.Post(ctx, "/api/v1/admin/hooks", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditHook updates an existing global hook (admin only). +func (s *AdminService) EditHook(ctx context.Context, id int64, opts *types.EditHookOption) (*types.Hook, error) { + path := ResolvePath("/api/v1/admin/hooks/{id}", Params{"id": int64String(id)}) + var out types.Hook + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteHook deletes a global hook (admin only). +func (s *AdminService) DeleteHook(ctx context.Context, id int64) error { + path := ResolvePath("/api/v1/admin/hooks/{id}", Params{"id": int64String(id)}) + return s.client.Delete(ctx, path) +} + +// ListQuotaGroups returns all available quota groups. +func (s *AdminService) ListQuotaGroups(ctx context.Context) ([]types.QuotaGroup, error) { + return ListAll[types.QuotaGroup](ctx, s.client, "/api/v1/admin/quota/groups", nil) +} + +// IterQuotaGroups returns an iterator over all available quota groups. +func (s *AdminService) IterQuotaGroups(ctx context.Context) iter.Seq2[types.QuotaGroup, error] { + return func(yield func(types.QuotaGroup, error) bool) { + groups, err := s.ListQuotaGroups(ctx) + if err != nil { + yield(*new(types.QuotaGroup), err) + return + } + for _, group := range groups { + if !yield(group, nil) { + return + } + } + } +} + +// CreateQuotaGroup creates a new quota group. +func (s *AdminService) CreateQuotaGroup(ctx context.Context, opts *types.CreateQuotaGroupOptions) (*types.QuotaGroup, error) { + var out types.QuotaGroup + if err := s.client.Post(ctx, "/api/v1/admin/quota/groups", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetQuotaGroup returns information about a quota group. +func (s *AdminService) GetQuotaGroup(ctx context.Context, quotagroup string) (*types.QuotaGroup, error) { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}", Params{"quotagroup": quotagroup}) + var out types.QuotaGroup + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteQuotaGroup deletes a quota group. +func (s *AdminService) DeleteQuotaGroup(ctx context.Context, quotagroup string) error { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}", Params{"quotagroup": quotagroup}) + return s.client.Delete(ctx, path) +} + +// AddQuotaGroupRule adds a quota rule to a quota group. +func (s *AdminService) AddQuotaGroupRule(ctx context.Context, quotagroup, quotarule string) error { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/rules/{quotarule}", Params{"quotagroup": quotagroup, "quotarule": quotarule}) + return s.client.Put(ctx, path, nil, nil) +} + +// RemoveQuotaGroupRule removes a quota rule from a quota group. +func (s *AdminService) RemoveQuotaGroupRule(ctx context.Context, quotagroup, quotarule string) error { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/rules/{quotarule}", Params{"quotagroup": quotagroup, "quotarule": quotarule}) + return s.client.Delete(ctx, path) +} + +// ListQuotaGroupUsers returns all users in a quota group. +func (s *AdminService) ListQuotaGroupUsers(ctx context.Context, quotagroup string) ([]types.User, error) { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/users", Params{"quotagroup": quotagroup}) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterQuotaGroupUsers returns an iterator over all users in a quota group. +func (s *AdminService) IterQuotaGroupUsers(ctx context.Context, quotagroup string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/users", Params{"quotagroup": quotagroup}) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// AddQuotaGroupUser adds a user to a quota group. +func (s *AdminService) AddQuotaGroupUser(ctx context.Context, quotagroup, username string) error { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/users/{username}", Params{"quotagroup": quotagroup, "username": username}) + return s.client.Put(ctx, path, nil, nil) +} + +// RemoveQuotaGroupUser removes a user from a quota group. +func (s *AdminService) RemoveQuotaGroupUser(ctx context.Context, quotagroup, username string) error { + path := ResolvePath("/api/v1/admin/quota/groups/{quotagroup}/users/{username}", Params{"quotagroup": quotagroup, "username": username}) + return s.client.Delete(ctx, path) +} + +// ListQuotaRules returns all available quota rules. +func (s *AdminService) ListQuotaRules(ctx context.Context) ([]types.QuotaRuleInfo, error) { + return ListAll[types.QuotaRuleInfo](ctx, s.client, "/api/v1/admin/quota/rules", nil) +} + +// IterQuotaRules returns an iterator over all available quota rules. +func (s *AdminService) IterQuotaRules(ctx context.Context) iter.Seq2[types.QuotaRuleInfo, error] { + return func(yield func(types.QuotaRuleInfo, error) bool) { + rules, err := s.ListQuotaRules(ctx) + if err != nil { + yield(*new(types.QuotaRuleInfo), err) + return + } + for _, rule := range rules { + if !yield(rule, nil) { + return + } + } + } +} + +// CreateQuotaRule creates a new quota rule. +func (s *AdminService) CreateQuotaRule(ctx context.Context, opts *types.CreateQuotaRuleOptions) (*types.QuotaRuleInfo, error) { + var out types.QuotaRuleInfo + if err := s.client.Post(ctx, "/api/v1/admin/quota/rules", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetQuotaRule returns information about a quota rule. +func (s *AdminService) GetQuotaRule(ctx context.Context, quotarule string) (*types.QuotaRuleInfo, error) { + path := ResolvePath("/api/v1/admin/quota/rules/{quotarule}", Params{"quotarule": quotarule}) + var out types.QuotaRuleInfo + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditQuotaRule updates an existing quota rule. +func (s *AdminService) EditQuotaRule(ctx context.Context, quotarule string, opts *types.EditQuotaRuleOptions) (*types.QuotaRuleInfo, error) { + path := ResolvePath("/api/v1/admin/quota/rules/{quotarule}", Params{"quotarule": quotarule}) + var out types.QuotaRuleInfo + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteQuotaRule deletes a quota rule. +func (s *AdminService) DeleteQuotaRule(ctx context.Context, quotarule string) error { + path := ResolvePath("/api/v1/admin/quota/rules/{quotarule}", Params{"quotarule": quotarule}) + return s.client.Delete(ctx, path) +} + +// ListUnadoptedRepos returns all unadopted repositories on the instance. +func (s *AdminService) ListUnadoptedRepos(ctx context.Context, filters ...AdminUnadoptedListOptions) ([]string, error) { + return ListAll[string](ctx, s.client, "/api/v1/admin/unadopted", adminUnadoptedQuery(filters...)) +} + +// IterUnadoptedRepos returns an iterator over all unadopted repositories on the instance. +func (s *AdminService) IterUnadoptedRepos(ctx context.Context, filters ...AdminUnadoptedListOptions) iter.Seq2[string, error] { + return ListIter[string](ctx, s.client, "/api/v1/admin/unadopted", adminUnadoptedQuery(filters...)) +} + +// SearchEmails searches all email addresses by keyword (admin only). +func (s *AdminService) SearchEmails(ctx context.Context, q string) ([]types.Email, error) { + return ListAll[types.Email](ctx, s.client, "/api/v1/admin/emails/search", map[string]string{"q": q}) +} + +// IterSearchEmails returns an iterator over all email addresses matching a keyword (admin only). +func (s *AdminService) IterSearchEmails(ctx context.Context, q string) iter.Seq2[types.Email, error] { + return ListIter[types.Email](ctx, s.client, "/api/v1/admin/emails/search", map[string]string{"q": q}) +} + // RunCron runs a cron task by name (admin only). func (s *AdminService) RunCron(ctx context.Context, task string) error { path := ResolvePath("/api/v1/admin/cron/{task}", Params{"task": task}) @@ -81,12 +429,103 @@ func (s *AdminService) IterCron(ctx context.Context) iter.Seq2[types.Cron, error return ListIter[types.Cron](ctx, s.client, "/api/v1/admin/cron", nil) } +// ListActionsRuns returns a single page of Actions workflow runs across the instance. +func (s *AdminService) ListActionsRuns(ctx context.Context, filters AdminActionsRunListOptions, opts ListOptions) (*PagedResult[types.ActionTask], error) { + if opts.Page < 1 { + opts.Page = 1 + } + if opts.Limit < 1 { + opts.Limit = 50 + } + + u, err := url.Parse("/api/v1/admin/actions/runs") + if err != nil { + return nil, core.E("AdminService.ListActionsRuns", "forge: parse path", err) + } + + q := u.Query() + for key, value := range filters.queryParams() { + q.Set(key, value) + } + q.Set("page", strconv.Itoa(opts.Page)) + q.Set("limit", strconv.Itoa(opts.Limit)) + u.RawQuery = q.Encode() + + var out types.ActionTaskResponse + resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + items := make([]types.ActionTask, 0, len(out.Entries)) + for _, run := range out.Entries { + if run != nil { + items = append(items, *run) + } + } + + return &PagedResult[types.ActionTask]{ + Items: items, + TotalCount: totalCount, + Page: opts.Page, + HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= opts.Limit), + }, nil +} + +// IterActionsRuns returns an iterator over all Actions workflow runs across the instance. +func (s *AdminService) IterActionsRuns(ctx context.Context, filters AdminActionsRunListOptions) iter.Seq2[types.ActionTask, error] { + return func(yield func(types.ActionTask, error) bool) { + page := 1 + for { + result, err := s.ListActionsRuns(ctx, filters, ListOptions{Page: page, Limit: 50}) + if err != nil { + yield(*new(types.ActionTask), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + // AdoptRepo adopts an unadopted repository (admin only). func (s *AdminService) AdoptRepo(ctx context.Context, owner, repo string) error { path := ResolvePath("/api/v1/admin/unadopted/{owner}/{repo}", Params{"owner": owner, "repo": repo}) return s.client.Post(ctx, path, nil, nil) } +// DeleteUnadoptedRepo deletes an unadopted repository's files. +func (s *AdminService) DeleteUnadoptedRepo(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/admin/unadopted/{owner}/{repo}", Params{"owner": owner, "repo": repo}) + return s.client.Delete(ctx, path) +} + +func adminUnadoptedQuery(filters ...AdminUnadoptedListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 1) + for _, filter := range filters { + if filter.Pattern != "" { + query["pattern"] = filter.Pattern + } + } + if len(query) == 0 { + return nil + } + return query +} + // GenerateRunnerToken generates an actions runner registration token. func (s *AdminService) GenerateRunnerToken(ctx context.Context) (string, error) { var out struct { diff --git a/admin_test.go b/admin_test.go index 8901d7f..8b31cb2 100644 --- a/admin_test.go +++ b/admin_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestAdminService_Good_ListUsers(t *testing.T) { +func TestAdminService_ListUsers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -39,7 +39,7 @@ func TestAdminService_Good_ListUsers(t *testing.T) { } } -func TestAdminService_Good_CreateUser(t *testing.T) { +func TestAdminService_CreateUser_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -78,7 +78,7 @@ func TestAdminService_Good_CreateUser(t *testing.T) { } } -func TestAdminService_Good_DeleteUser(t *testing.T) { +func TestAdminService_DeleteUser_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -96,7 +96,7 @@ func TestAdminService_Good_DeleteUser(t *testing.T) { } } -func TestAdminService_Good_RunCron(t *testing.T) { +func TestAdminService_RunCron_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -114,7 +114,7 @@ func TestAdminService_Good_RunCron(t *testing.T) { } } -func TestAdminService_Good_EditUser(t *testing.T) { +func TestAdminService_EditUser_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) @@ -142,7 +142,7 @@ func TestAdminService_Good_EditUser(t *testing.T) { } } -func TestAdminService_Good_RenameUser(t *testing.T) { +func TestAdminService_RenameUser_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -167,7 +167,7 @@ func TestAdminService_Good_RenameUser(t *testing.T) { } } -func TestAdminService_Good_ListOrgs(t *testing.T) { +func TestAdminService_ListOrgs_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -195,7 +195,633 @@ func TestAdminService_Good_ListOrgs(t *testing.T) { } } -func TestAdminService_Good_ListCron(t *testing.T) { +func TestAdminService_ListEmails_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/emails" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Email{ + {Email: "alice@example.com", Primary: true}, + {Email: "bob@example.com", Verified: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + emails, err := f.Admin.ListEmails(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(emails) != 2 { + t.Errorf("got %d emails, want 2", len(emails)) + } + if emails[0].Email != "alice@example.com" || !emails[0].Primary { + t.Errorf("got first email=%+v, want primary alice@example.com", emails[0]) + } +} + +func TestAdminService_ListHooks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Hook{ + {ID: 7, Type: "forgejo", URL: "https://example.com/admin-hook", Active: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hooks, err := f.Admin.ListHooks(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(hooks) != 1 { + t.Fatalf("got %d hooks, want 1", len(hooks)) + } + if hooks[0].ID != 7 || hooks[0].URL != "https://example.com/admin-hook" { + t.Errorf("unexpected hook: %+v", hooks[0]) + } +} + +func TestAdminService_CreateHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Type != "forgejo" { + t.Errorf("got type=%q, want %q", opts.Type, "forgejo") + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 12, + Type: opts.Type, + Active: opts.Active, + Events: opts.Events, + URL: "https://example.com/admin-hook", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Admin.CreateHook(context.Background(), &types.CreateHookOption{ + Type: "forgejo", + Active: true, + Events: []string{"push"}, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 12 { + t.Errorf("got id=%d, want 12", hook.ID) + } + if hook.Type != "forgejo" { + t.Errorf("got type=%q, want %q", hook.Type, "forgejo") + } +} + +func TestAdminService_GetHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/hooks/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 7, + Type: "forgejo", + Active: true, + URL: "https://example.com/admin-hook", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Admin.GetHook(context.Background(), 7) + if err != nil { + t.Fatal(err) + } + if hook.ID != 7 { + t.Errorf("got id=%d, want 7", hook.ID) + } +} + +func TestAdminService_EditHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/hooks/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if !opts.Active { + t.Error("expected active=true") + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 7, + Type: "forgejo", + Active: opts.Active, + URL: "https://example.com/admin-hook", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Admin.EditHook(context.Background(), 7, &types.EditHookOption{Active: true}) + if err != nil { + t.Fatal(err) + } + if hook.ID != 7 || !hook.Active { + t.Errorf("unexpected hook: %+v", hook) + } +} + +func TestAdminService_DeleteHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/hooks/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.DeleteHook(context.Background(), 7); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_ListQuotaGroups_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.QuotaGroup{ + { + Name: "default", + Rules: []*types.QuotaRuleInfo{ + {Name: "git", Limit: 200000000, Subjects: []string{"size:repos:all"}}, + }, + }, + { + Name: "premium", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + groups, err := f.Admin.ListQuotaGroups(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 { + t.Fatalf("got %d groups, want 2", len(groups)) + } + if groups[0].Name != "default" { + t.Errorf("got name=%q, want %q", groups[0].Name, "default") + } + if len(groups[0].Rules) != 1 || groups[0].Rules[0].Name != "git" { + t.Errorf("unexpected rules: %+v", groups[0].Rules) + } +} + +func TestAdminService_IterQuotaGroups_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.QuotaGroup{ + {Name: "default"}, + {Name: "premium"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for group, err := range f.Admin.IterQuotaGroups(context.Background()) { + if err != nil { + t.Fatal(err) + } + got = append(got, group.Name) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 2 || got[0] != "default" || got[1] != "premium" { + t.Fatalf("got %#v", got) + } +} + +func TestAdminService_CreateQuotaGroup_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateQuotaGroupOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Name != "newgroup" { + t.Errorf("got name=%q, want %q", opts.Name, "newgroup") + } + if len(opts.Rules) != 1 || opts.Rules[0].Name != "git" { + t.Fatalf("unexpected rules: %+v", opts.Rules) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.QuotaGroup{ + Name: opts.Name, + Rules: []*types.QuotaRuleInfo{ + { + Name: opts.Rules[0].Name, + Limit: opts.Rules[0].Limit, + Subjects: opts.Rules[0].Subjects, + }, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + group, err := f.Admin.CreateQuotaGroup(context.Background(), &types.CreateQuotaGroupOptions{ + Name: "newgroup", + Rules: []*types.CreateQuotaRuleOptions{ + { + Name: "git", + Limit: 200000000, + Subjects: []string{"size:repos:all"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if group.Name != "newgroup" { + t.Errorf("got name=%q, want %q", group.Name, "newgroup") + } + if len(group.Rules) != 1 || group.Rules[0].Limit != 200000000 { + t.Errorf("unexpected rules: %+v", group.Rules) + } +} + +func TestAdminService_GetQuotaGroup_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups/default" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.QuotaGroup{ + Name: "default", + Rules: []*types.QuotaRuleInfo{ + {Name: "git", Limit: 200000000, Subjects: []string{"size:repos:all"}}, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + group, err := f.Admin.GetQuotaGroup(context.Background(), "default") + if err != nil { + t.Fatal(err) + } + if group.Name != "default" { + t.Errorf("got name=%q, want %q", group.Name, "default") + } + if len(group.Rules) != 1 || group.Rules[0].Name != "git" { + t.Fatalf("unexpected rules: %+v", group.Rules) + } +} + +func TestAdminService_DeleteQuotaGroup_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups/default" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.DeleteQuotaGroup(context.Background(), "default"); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_ListQuotaGroupUsers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups/default/users" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Admin.ListQuotaGroupUsers(context.Background(), "default") + if err != nil { + t.Fatal(err) + } + if len(users) != 2 { + t.Fatalf("got %d users, want 2", len(users)) + } + if users[0].UserName != "alice" { + t.Errorf("got username=%q, want %q", users[0].UserName, "alice") + } +} + +func TestAdminService_AddQuotaGroupUser_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups/default/users/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.AddQuotaGroupUser(context.Background(), "default", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_RemoveQuotaGroupUser_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/groups/default/users/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.RemoveQuotaGroupUser(context.Background(), "default", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_ListQuotaRules_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.QuotaRuleInfo{ + {Name: "git", Limit: 200000000, Subjects: []string{"size:repos:all"}}, + {Name: "artifacts", Limit: 50000000, Subjects: []string{"size:assets:artifacts"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + rules, err := f.Admin.ListQuotaRules(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rules) != 2 { + t.Fatalf("got %d rules, want 2", len(rules)) + } + if rules[0].Name != "git" { + t.Errorf("got name=%q, want %q", rules[0].Name, "git") + } +} + +func TestAdminService_IterQuotaRules_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.QuotaRuleInfo{ + {Name: "git"}, + {Name: "artifacts"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for rule, err := range f.Admin.IterQuotaRules(context.Background()) { + if err != nil { + t.Fatal(err) + } + got = append(got, rule.Name) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 2 || got[0] != "git" || got[1] != "artifacts" { + t.Fatalf("got %#v", got) + } +} + +func TestAdminService_CreateQuotaRule_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateQuotaRuleOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Name != "git" || opts.Limit != 200000000 { + t.Fatalf("unexpected options: %+v", opts) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.QuotaRuleInfo{ + Name: opts.Name, + Limit: opts.Limit, + Subjects: opts.Subjects, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + rule, err := f.Admin.CreateQuotaRule(context.Background(), &types.CreateQuotaRuleOptions{ + Name: "git", + Limit: 200000000, + Subjects: []string{"size:repos:all"}, + }) + if err != nil { + t.Fatal(err) + } + if rule.Name != "git" || rule.Limit != 200000000 { + t.Errorf("unexpected rule: %+v", rule) + } +} + +func TestAdminService_GetQuotaRule_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules/git" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.QuotaRuleInfo{ + Name: "git", + Limit: 200000000, + Subjects: []string{"size:repos:all"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + rule, err := f.Admin.GetQuotaRule(context.Background(), "git") + if err != nil { + t.Fatal(err) + } + if rule.Name != "git" { + t.Errorf("got name=%q, want %q", rule.Name, "git") + } +} + +func TestAdminService_EditQuotaRule_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules/git" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditQuotaRuleOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Limit != 500000000 { + t.Fatalf("unexpected options: %+v", opts) + } + json.NewEncoder(w).Encode(types.QuotaRuleInfo{ + Name: "git", + Limit: opts.Limit, + Subjects: opts.Subjects, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + rule, err := f.Admin.EditQuotaRule(context.Background(), "git", &types.EditQuotaRuleOptions{ + Limit: 500000000, + Subjects: []string{"size:repos:all", "size:assets:packages"}, + }) + if err != nil { + t.Fatal(err) + } + if rule.Limit != 500000000 { + t.Errorf("got limit=%d, want 500000000", rule.Limit) + } +} + +func TestAdminService_DeleteQuotaRule_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/quota/rules/git" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.DeleteQuotaRule(context.Background(), "git"); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_SearchEmails_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/emails/search" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("q"); got != "alice" { + t.Errorf("got q=%q, want %q", got, "alice") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Email{ + {Email: "alice@example.com", Primary: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + emails, err := f.Admin.SearchEmails(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(emails) != 1 { + t.Errorf("got %d emails, want 1", len(emails)) + } + if emails[0].Email != "alice@example.com" { + t.Errorf("got email=%q, want %q", emails[0].Email, "alice@example.com") + } +} + +func TestAdminService_ListCron_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -223,7 +849,7 @@ func TestAdminService_Good_ListCron(t *testing.T) { } } -func TestAdminService_Good_AdoptRepo(t *testing.T) { +func TestAdminService_AdoptRepo_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -241,7 +867,153 @@ func TestAdminService_Good_AdoptRepo(t *testing.T) { } } -func TestAdminService_Good_GenerateRunnerToken(t *testing.T) { +func TestAdminService_ListUnadoptedRepos_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/unadopted" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("pattern"); got != "core/*" { + t.Errorf("got pattern=%q, want %q", got, "core/*") + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("got limit=%q, want %q", got, "50") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]string{"core/myrepo"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Admin.ListUnadoptedRepos(context.Background(), AdminUnadoptedListOptions{Pattern: "core/*"}) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 || repos[0] != "core/myrepo" { + t.Fatalf("unexpected result: %#v", repos) + } +} + +func TestAdminService_DeleteUnadoptedRepo_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/unadopted/alice/myrepo" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Admin.DeleteUnadoptedRepo(context.Background(), "alice", "myrepo"); err != nil { + t.Fatal(err) + } +} + +func TestAdminService_ListActionsRuns_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/admin/actions/runs" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("status"); got != "in_progress" { + t.Errorf("got status=%q, want %q", got, "in_progress") + } + if got := r.URL.Query().Get("branch"); got != "main" { + t.Errorf("got branch=%q, want %q", got, "main") + } + if got := r.URL.Query().Get("actor"); got != "alice" { + t.Errorf("got actor=%q, want %q", got, "alice") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{ + {ID: 101, Name: "build", Status: "in_progress", Event: "push"}, + {ID: 102, Name: "test", Status: "queued", Event: "push"}, + }, + TotalCount: 3, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Admin.ListActionsRuns(context.Background(), AdminActionsRunListOptions{ + Status: "in_progress", + Branch: "main", + Actor: "alice", + }, ListOptions{Page: 2, Limit: 25}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 3 { + t.Fatalf("got total count=%d, want 3", result.TotalCount) + } + if len(result.Items) != 2 { + t.Fatalf("got %d runs, want 2", len(result.Items)) + } + if result.Items[0].ID != 101 || result.Items[0].Name != "build" { + t.Errorf("unexpected first run: %+v", result.Items[0]) + } +} + +func TestAdminService_IterActionsRuns_Good(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/api/v1/admin/actions/runs" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + switch calls { + case 1: + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{ + {ID: 201, Name: "build"}, + }, + TotalCount: 2, + }) + default: + json.NewEncoder(w).Encode(types.ActionTaskResponse{ + Entries: []*types.ActionTask{ + {ID: 202, Name: "test"}, + }, + TotalCount: 2, + }) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var ids []int64 + for run, err := range f.Admin.IterActionsRuns(context.Background(), AdminActionsRunListOptions{}) { + if err != nil { + t.Fatal(err) + } + ids = append(ids, run.ID) + } + if len(ids) != 2 || ids[0] != 201 || ids[1] != 202 { + t.Fatalf("unexpected run ids: %v", ids) + } +} + +func TestAdminService_GenerateRunnerToken_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -263,7 +1035,7 @@ func TestAdminService_Good_GenerateRunnerToken(t *testing.T) { } } -func TestAdminService_Bad_DeleteUser_NotFound(t *testing.T) { +func TestAdminService_DeleteUser_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "user not found"}) @@ -277,7 +1049,7 @@ func TestAdminService_Bad_DeleteUser_NotFound(t *testing.T) { } } -func TestAdminService_Bad_CreateUser_Forbidden(t *testing.T) { +func TestAdminService_CreateUser_Forbidden_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{"message": "only admins can create users"}) diff --git a/ax_stringer_test.go b/ax_stringer_test.go new file mode 100644 index 0000000..e638ca5 --- /dev/null +++ b/ax_stringer_test.go @@ -0,0 +1,266 @@ +package forge + +import ( + "fmt" + "testing" + "time" +) + +func TestParams_String_Good(t *testing.T) { + params := Params{"repo": "go-forge", "owner": "core"} + want := `forge.Params{owner="core", repo="go-forge"}` + if got := params.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(params); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", params); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestParams_String_NilSafe(t *testing.T) { + var params Params + want := "forge.Params{}" + if got := params.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(params); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", params); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestListOptions_String_Good(t *testing.T) { + opts := ListOptions{Page: 2, Limit: 25} + want := "forge.ListOptions{page=2, limit=25}" + if got := opts.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(opts); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", opts); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestRateLimit_String_Good(t *testing.T) { + rl := RateLimit{Limit: 80, Remaining: 79, Reset: 1700000003} + want := "forge.RateLimit{limit=80, remaining=79, reset=1700000003}" + if got := rl.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(rl); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", rl); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestPagedResult_String_Good(t *testing.T) { + page := PagedResult[int]{ + Items: []int{1, 2, 3}, + TotalCount: 10, + Page: 2, + HasMore: true, + } + want := "forge.PagedResult{items=3, totalCount=10, page=2, hasMore=true}" + if got := page.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(page); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", page); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestOption_Stringers_Good(t *testing.T) { + when := time.Date(2026, time.April, 2, 8, 3, 4, 0, time.UTC) + + cases := []struct { + name string + got fmt.Stringer + want string + }{ + { + name: "AdminActionsRunListOptions", + got: AdminActionsRunListOptions{Event: "push", Status: "success"}, + want: `forge.AdminActionsRunListOptions{event="push", status="success"}`, + }, + { + name: "AttachmentUploadOptions", + got: AttachmentUploadOptions{Name: "screenshot.png", UpdatedAt: &when}, + want: `forge.AttachmentUploadOptions{name="screenshot.png", updated_at="2026-04-02T08:03:04Z"}`, + }, + { + name: "NotificationListOptions", + got: NotificationListOptions{All: true, StatusTypes: []string{"unread"}, SubjectTypes: []string{"issue"}}, + want: `forge.NotificationListOptions{all=true, status_types=[]string{"unread"}, subject_types=[]string{"issue"}}`, + }, + { + name: "SearchIssuesOptions", + got: SearchIssuesOptions{State: "open", PriorityRepoID: 99, Assigned: true, Query: "build"}, + want: `forge.SearchIssuesOptions{state="open", q="build", priority_repo_id=99, assigned=true}`, + }, + { + name: "IssueListOptions", + got: IssueListOptions{State: "open", Labels: "bug", Query: "panic", CreatedBy: "alice"}, + want: `forge.IssueListOptions{state="open", labels="bug", q="panic", created_by="alice"}`, + }, + { + name: "PullListOptions", + got: PullListOptions{State: "open", Sort: "priority", Milestone: 7, Labels: []int64{1, 2}, Poster: "alice"}, + want: `forge.PullListOptions{state="open", sort="priority", milestone=7, labels=[]int64{1, 2}, poster="alice"}`, + }, + { + name: "ReleaseListOptions", + got: ReleaseListOptions{Draft: true, PreRelease: true, Query: "1.0"}, + want: `forge.ReleaseListOptions{draft=true, pre-release=true, q="1.0"}`, + }, + { + name: "CommitListOptions", + got: func() CommitListOptions { + stat := false + verification := false + files := false + return CommitListOptions{ + Sha: "main", + Path: "docs", + Stat: &stat, + Verification: &verification, + Files: &files, + Not: "deadbeef", + } + }(), + want: `forge.CommitListOptions{sha="main", path="docs", stat=false, verification=false, files=false, not="deadbeef"}`, + }, + { + name: "ReleaseAttachmentUploadOptions", + got: ReleaseAttachmentUploadOptions{Name: "release.zip"}, + want: `forge.ReleaseAttachmentUploadOptions{name="release.zip"}`, + }, + { + name: "UserSearchOptions", + got: UserSearchOptions{UID: 1001}, + want: `forge.UserSearchOptions{uid=1001}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.got.String(); got != tc.want { + t.Fatalf("got String()=%q, want %q", got, tc.want) + } + if got := fmt.Sprint(tc.got); got != tc.want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, tc.want) + } + if got := fmt.Sprintf("%#v", tc.got); got != tc.want { + t.Fatalf("got GoString=%q, want %q", got, tc.want) + } + }) + } +} + +func TestOption_Stringers_Empty(t *testing.T) { + cases := []struct { + name string + got fmt.Stringer + want string + }{ + { + name: "AdminUnadoptedListOptions", + got: AdminUnadoptedListOptions{}, + want: `forge.AdminUnadoptedListOptions{}`, + }, + { + name: "MilestoneListOptions", + got: MilestoneListOptions{}, + want: `forge.MilestoneListOptions{}`, + }, + { + name: "UserKeyListOptions", + got: UserKeyListOptions{}, + want: `forge.UserKeyListOptions{}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.got.String(); got != tc.want { + t.Fatalf("got String()=%q, want %q", got, tc.want) + } + if got := fmt.Sprint(tc.got); got != tc.want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, tc.want) + } + if got := fmt.Sprintf("%#v", tc.got); got != tc.want { + t.Fatalf("got GoString=%q, want %q", got, tc.want) + } + }) + } +} + +func TestService_Stringers_Good(t *testing.T) { + client := NewClient("https://forge.example", "token") + + cases := []struct { + name string + got fmt.Stringer + want string + }{ + { + name: "RepoService", + got: newRepoService(client), + want: `forge.RepoService{resource=forge.Resource{path="/api/v1/repos/{owner}/{repo}", collection="/api/v1/repos/{owner}"}}`, + }, + { + name: "AdminService", + got: newAdminService(client), + want: `forge.AdminService{client=forge.Client{baseURL="https://forge.example", token=set, userAgent="go-forge/0.1"}}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.got.String(); got != tc.want { + t.Fatalf("got String()=%q, want %q", got, tc.want) + } + if got := fmt.Sprint(tc.got); got != tc.want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, tc.want) + } + if got := fmt.Sprintf("%#v", tc.got); got != tc.want { + t.Fatalf("got GoString=%q, want %q", got, tc.want) + } + }) + } +} + +func TestService_Stringers_NilSafe(t *testing.T) { + var repo *RepoService + if got, want := repo.String(), "forge.RepoService{}"; got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got, want := fmt.Sprint(repo), "forge.RepoService{}"; got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got, want := fmt.Sprintf("%#v", repo), "forge.RepoService{}"; got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } + + var admin *AdminService + if got, want := admin.String(), "forge.AdminService{}"; got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got, want := fmt.Sprint(admin), "forge.AdminService{}"; got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got, want := fmt.Sprintf("%#v", admin), "forge.AdminService{}"; got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} diff --git a/branches.go b/branches.go index 66af9a4..e7d4320 100644 --- a/branches.go +++ b/branches.go @@ -2,40 +2,87 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" ) // BranchService handles branch operations within a repository. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Branches.ListBranchProtections(ctx, "core", "go-forge") type BranchService struct { - Resource[types.Branch, types.CreateBranchRepoOption, struct{}] + Resource[types.Branch, types.CreateBranchRepoOption, types.UpdateBranchRepoOption] } func newBranchService(c *Client) *BranchService { return &BranchService{ - Resource: *NewResource[types.Branch, types.CreateBranchRepoOption, struct{}]( + Resource: *NewResource[types.Branch, types.CreateBranchRepoOption, types.UpdateBranchRepoOption]( c, "/api/v1/repos/{owner}/{repo}/branches/{branch}", ), } } +// ListBranches returns all branches for a repository. +func (s *BranchService) ListBranches(ctx context.Context, owner, repo string) ([]types.Branch, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Branch](ctx, s.client, path, nil) +} + +// IterBranches returns an iterator over all branches for a repository. +func (s *BranchService) IterBranches(ctx context.Context, owner, repo string) iter.Seq2[types.Branch, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Branch](ctx, s.client, path, nil) +} + +// CreateBranch creates a new branch in a repository. +func (s *BranchService) CreateBranch(ctx context.Context, owner, repo string, opts *types.CreateBranchRepoOption) (*types.Branch, error) { + var out types.Branch + if err := s.client.Post(ctx, ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)), opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetBranch returns a single branch by name. +func (s *BranchService) GetBranch(ctx context.Context, owner, repo, branch string) (*types.Branch, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches/{branch}", pathParams("owner", owner, "repo", repo, "branch", branch)) + var out types.Branch + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateBranch renames a branch in a repository. +func (s *BranchService) UpdateBranch(ctx context.Context, owner, repo, branch string, opts *types.UpdateBranchRepoOption) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches/{branch}", pathParams("owner", owner, "repo", repo, "branch", branch)) + return s.client.Patch(ctx, path, opts, nil) +} + +// DeleteBranch removes a branch from a repository. +func (s *BranchService) DeleteBranch(ctx context.Context, owner, repo, branch string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches/{branch}", pathParams("owner", owner, "repo", repo, "branch", branch)) + return s.client.Delete(ctx, path) +} + // ListBranchProtections returns all branch protections for a repository. func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections", pathParams("owner", owner, "repo", repo)) return ListAll[types.BranchProtection](ctx, s.client, path, nil) } // IterBranchProtections returns an iterator over all branch protections for a repository. func (s *BranchService) IterBranchProtections(ctx context.Context, owner, repo string) iter.Seq2[types.BranchProtection, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections", pathParams("owner", owner, "repo", repo)) return ListIter[types.BranchProtection](ctx, s.client, path, nil) } // GetBranchProtection returns a single branch protection by name. func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, name string) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections/{name}", pathParams("owner", owner, "repo", repo, "name", name)) var out types.BranchProtection if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -45,7 +92,7 @@ func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, na // CreateBranchProtection creates a new branch protection rule. func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo string, opts *types.CreateBranchProtectionOption) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections", pathParams("owner", owner, "repo", repo)) var out types.BranchProtection if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -55,7 +102,7 @@ func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo // EditBranchProtection updates an existing branch protection rule. func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, name string, opts *types.EditBranchProtectionOption) (*types.BranchProtection, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections/{name}", pathParams("owner", owner, "repo", repo, "name", name)) var out types.BranchProtection if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -65,6 +112,6 @@ func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, n // DeleteBranchProtection deletes a branch protection rule. func (s *BranchService) DeleteBranchProtection(ctx context.Context, owner, repo, name string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections/{name}", pathParams("owner", owner, "repo", repo, "name", name)) return s.client.Delete(ctx, path) } diff --git a/branches_extra_test.go b/branches_extra_test.go new file mode 100644 index 0000000..ce1bbfb --- /dev/null +++ b/branches_extra_test.go @@ -0,0 +1,66 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestBranchService_ListBranches_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/branches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Branch{{Name: "main"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + branches, err := f.Branches.ListBranches(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(branches) != 1 || branches[0].Name != "main" { + t.Fatalf("got %#v", branches) + } +} + +func TestBranchService_CreateBranch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/branches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateBranchRepoOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.BranchName != "release/v1" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.Branch{Name: body.BranchName}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + branch, err := f.Branches.CreateBranch(context.Background(), "core", "go-forge", &types.CreateBranchRepoOption{ + BranchName: "release/v1", + OldRefName: "main", + }) + if err != nil { + t.Fatal(err) + } + if branch.Name != "release/v1" { + t.Fatalf("got name=%q", branch.Name) + } +} diff --git a/branches_test.go b/branches_test.go index 22d1302..650507d 100644 --- a/branches_test.go +++ b/branches_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestBranchService_Good_List(t *testing.T) { +func TestBranchService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -36,7 +36,7 @@ func TestBranchService_Good_List(t *testing.T) { } } -func TestBranchService_Good_Get(t *testing.T) { +func TestBranchService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -61,7 +61,34 @@ func TestBranchService_Good_Get(t *testing.T) { } } -func TestBranchService_Good_CreateProtection(t *testing.T) { +func TestBranchService_UpdateBranch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/branches/main" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.UpdateBranchRepoOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Name != "develop" { + t.Errorf("got name=%q, want %q", opts.Name, "develop") + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Branches.UpdateBranch(context.Background(), "core", "go-forge", "main", &types.UpdateBranchRepoOption{ + Name: "develop", + }); err != nil { + t.Fatal(err) + } +} + +func TestBranchService_CreateProtection_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) diff --git a/client.go b/client.go index cb10233..1b650a7 100644 --- a/client.go +++ b/client.go @@ -3,67 +3,160 @@ package forge import ( "bytes" "context" - "encoding/json" - "errors" - "fmt" - "io" + json "github.com/goccy/go-json" + "mime/multipart" "net/http" + "net/url" "strconv" - "strings" - coreerr "dappco.re/go/core/log" + goio "io" + + core "dappco.re/go/core" ) // APIError represents an error response from the Forgejo API. +// +// Usage: +// +// if apiErr, ok := err.(*forge.APIError); ok { +// _ = apiErr.StatusCode +// } type APIError struct { StatusCode int Message string URL string } +// Error returns the formatted Forge API error string. +// +// Usage: +// +// err := (&forge.APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"}).Error() func (e *APIError) Error() string { - return fmt.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) + if e == nil { + return "forge.APIError{}" + } + return core.Concat("forge: ", e.URL, " ", strconv.Itoa(e.StatusCode), ": ", e.Message) } +// String returns a safe summary of the API error. +// +// Usage: +// +// s := err.String() +func (e *APIError) String() string { return e.Error() } + +// GoString returns a safe Go-syntax summary of the API error. +// +// Usage: +// +// s := fmt.Sprintf("%#v", err) +func (e *APIError) GoString() string { return e.Error() } + // IsNotFound returns true if the error is a 404 response. +// +// Usage: +// +// if forge.IsNotFound(err) { +// return nil +// } func IsNotFound(err error) bool { var apiErr *APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound + return core.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } // IsForbidden returns true if the error is a 403 response. +// +// Usage: +// +// if forge.IsForbidden(err) { +// return nil +// } func IsForbidden(err error) bool { var apiErr *APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden + return core.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden } // IsConflict returns true if the error is a 409 response. +// +// Usage: +// +// if forge.IsConflict(err) { +// return nil +// } func IsConflict(err error) bool { var apiErr *APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict + return core.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict } // Option configures the Client. +// +// Usage: +// +// opts := []forge.Option{forge.WithUserAgent("go-forge/1.0")} type Option func(*Client) // WithHTTPClient sets a custom http.Client. +// +// Usage: +// +// c := forge.NewClient(url, token, forge.WithHTTPClient(http.DefaultClient)) func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } // WithUserAgent sets the User-Agent header. +// +// Usage: +// +// c := forge.NewClient(url, token, forge.WithUserAgent("go-forge/1.0")) func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } } // RateLimit represents the rate limit information from the Forgejo API. +// +// Usage: +// +// rl := client.RateLimit() +// _ = rl.Remaining type RateLimit struct { Limit int Remaining int Reset int64 } +// String returns a safe summary of the rate limit state. +// +// Usage: +// +// rl := client.RateLimit() +// _ = rl.String() +func (r RateLimit) String() string { + return core.Concat( + "forge.RateLimit{limit=", + strconv.Itoa(r.Limit), + ", remaining=", + strconv.Itoa(r.Remaining), + ", reset=", + strconv.FormatInt(r.Reset, 10), + "}", + ) +} + +// GoString returns a safe Go-syntax summary of the rate limit state. +// +// Usage: +// +// _ = fmt.Sprintf("%#v", client.RateLimit()) +func (r RateLimit) GoString() string { return r.String() } + // Client is a low-level HTTP client for the Forgejo API. +// +// Usage: +// +// c := forge.NewClient("https://forge.lthn.ai", "token") +// _ = c type Client struct { baseURL string token string @@ -72,15 +165,100 @@ type Client struct { rateLimit RateLimit } +// BaseURL returns the configured Forgejo base URL. +// +// Usage: +// +// baseURL := client.BaseURL() +func (c *Client) BaseURL() string { + if c == nil { + return "" + } + return c.baseURL +} + // RateLimit returns the last known rate limit information. +// +// Usage: +// +// rl := client.RateLimit() func (c *Client) RateLimit() RateLimit { + if c == nil { + return RateLimit{} + } return c.rateLimit } +// UserAgent returns the configured User-Agent header value. +// +// Usage: +// +// ua := client.UserAgent() +func (c *Client) UserAgent() string { + if c == nil { + return "" + } + return c.userAgent +} + +// HTTPClient returns the configured underlying HTTP client. +// +// Usage: +// +// hc := client.HTTPClient() +func (c *Client) HTTPClient() *http.Client { + if c == nil { + return nil + } + return c.httpClient +} + +// String returns a safe summary of the client configuration. +// +// Usage: +// +// s := client.String() +func (c *Client) String() string { + if c == nil { + return "forge.Client{}" + } + tokenState := "unset" + if c.HasToken() { + tokenState = "set" + } + return core.Concat("forge.Client{baseURL=", strconv.Quote(c.baseURL), ", token=", tokenState, ", userAgent=", strconv.Quote(c.userAgent), "}") +} + +// GoString returns a safe Go-syntax summary of the client configuration. +// +// Usage: +// +// s := fmt.Sprintf("%#v", client) +func (c *Client) GoString() string { return c.String() } + +// HasToken reports whether the client was configured with an API token. +// +// Usage: +// +// if c.HasToken() { +// _ = "authenticated" +// } +func (c *Client) HasToken() bool { + if c == nil { + return false + } + return c.token != "" +} + // NewClient creates a new Forgejo API client. +// +// Usage: +// +// c := forge.NewClient("https://forge.lthn.ai", "token") +// _ = c func NewClient(url, token string, opts ...Option) *Client { c := &Client{ - baseURL: strings.TrimRight(url, "/"), + baseURL: trimTrailingSlashes(url), token: token, httpClient: &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -96,36 +274,64 @@ func NewClient(url, token string, opts ...Option) *Client { } // Get performs a GET request. +// +// Usage: +// +// var out map[string]string +// err := client.Get(ctx, "/api/v1/user", &out) func (c *Client) Get(ctx context.Context, path string, out any) error { _, err := c.doJSON(ctx, http.MethodGet, path, nil, out) return err } // Post performs a POST request. +// +// Usage: +// +// var out map[string]any +// err := client.Post(ctx, "/api/v1/orgs/core/repos", body, &out) func (c *Client) Post(ctx context.Context, path string, body, out any) error { _, err := c.doJSON(ctx, http.MethodPost, path, body, out) return err } // Patch performs a PATCH request. +// +// Usage: +// +// var out map[string]any +// err := client.Patch(ctx, "/api/v1/repos/core/go-forge", body, &out) func (c *Client) Patch(ctx context.Context, path string, body, out any) error { _, err := c.doJSON(ctx, http.MethodPatch, path, body, out) return err } // Put performs a PUT request. +// +// Usage: +// +// var out map[string]any +// err := client.Put(ctx, "/api/v1/repos/core/go-forge", body, &out) func (c *Client) Put(ctx context.Context, path string, body, out any) error { _, err := c.doJSON(ctx, http.MethodPut, path, body, out) return err } // Delete performs a DELETE request. +// +// Usage: +// +// err := client.Delete(ctx, "/api/v1/repos/core/go-forge") func (c *Client) Delete(ctx context.Context, path string) error { _, err := c.doJSON(ctx, http.MethodDelete, path, nil, nil) return err } // DeleteWithBody performs a DELETE request with a JSON body. +// +// Usage: +// +// err := client.DeleteWithBody(ctx, "/api/v1/repos/core/go-forge/labels", body) func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error { _, err := c.doJSON(ctx, http.MethodDelete, path, body, nil) return err @@ -134,21 +340,29 @@ func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) erro // PostRaw performs a POST request with a JSON body and returns the raw // response body as bytes instead of JSON-decoding. Useful for endpoints // such as /markdown that return raw HTML text. +// +// Usage: +// +// body, err := client.PostRaw(ctx, "/api/v1/markdown", payload) func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error) { + return c.postRawJSON(ctx, path, body) +} + +func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte, error) { url := c.baseURL + path - var bodyReader io.Reader + var bodyReader goio.Reader if body != nil { data, err := json.Marshal(body) if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: marshal body", err) + return nil, core.E("Client.PostRaw", "forge: marshal body", err) } bodyReader = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader) if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: create request", err) + return nil, core.E("Client.PostRaw", "forge: create request", err) } req.Header.Set("Authorization", "token "+c.token) @@ -159,30 +373,136 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er resp, err := c.httpClient.Do(req) if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: request POST "+path, err) + return nil, core.E("Client.PostRaw", "forge: request POST "+path, err) + } + defer resp.Body.Close() + + c.updateRateLimit(resp) + + if resp.StatusCode >= 400 { + return nil, c.parseError(resp, path) + } + + data, err := goio.ReadAll(resp.Body) + if err != nil { + return nil, core.E("Client.PostRaw", "forge: read response body", err) + } + + return data, nil +} + +func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, error) { + url := c.baseURL + path + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) + if err != nil { + return nil, core.E("Client.PostText", "forge: create request", err) + } + + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "text/html") + req.Header.Set("Content-Type", "text/plain") + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, core.E("Client.PostText", "forge: request POST "+path, err) } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } - data, err := io.ReadAll(resp.Body) + data, err := goio.ReadAll(resp.Body) if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: read response body", err) + return nil, core.E("Client.PostText", "forge: read response body", err) } return data, nil } +func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content goio.Reader, out any) error { + target, err := url.Parse(c.baseURL + path) + if err != nil { + return core.E("Client.PostMultipart", "forge: parse url", err) + } + if len(query) > 0 { + values := target.Query() + for key, value := range query { + values.Set(key, value) + } + target.RawQuery = values.Encode() + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return core.E("Client.PostMultipart", "forge: create multipart form field", err) + } + } + if fieldName != "" { + part, err := writer.CreateFormFile(fieldName, fileName) + if err != nil { + return core.E("Client.PostMultipart", "forge: create multipart form file", err) + } + if content != nil { + if _, err := goio.Copy(part, content); err != nil { + return core.E("Client.PostMultipart", "forge: write multipart form file", err) + } + } + } + if err := writer.Close(); err != nil { + return core.E("Client.PostMultipart", "forge: close multipart writer", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), &body) + if err != nil { + return core.E("Client.PostMultipart", "forge: create request", err) + } + + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Content-Type", writer.FormDataContentType()) + if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return core.E("Client.PostMultipart", "forge: request POST "+path, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return c.parseError(resp, path) + } + + if out == nil { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return core.E("Client.PostMultipart", "forge: decode response body", err) + } + return nil +} + // GetRaw performs a GET request and returns the raw response body as bytes // instead of JSON-decoding. Useful for endpoints that return raw file content. +// +// Usage: +// +// body, err := client.GetRaw(ctx, "/api/v1/signing-key.gpg") func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { url := c.baseURL + path req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, coreerr.E("Client.GetRaw", "forge: create request", err) + return nil, core.E("Client.GetRaw", "forge: create request", err) } req.Header.Set("Authorization", "token "+c.token) @@ -192,17 +512,19 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { resp, err := c.httpClient.Do(req) if err != nil { - return nil, coreerr.E("Client.GetRaw", "forge: request GET "+path, err) + return nil, core.E("Client.GetRaw", "forge: request GET "+path, err) } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } - data, err := io.ReadAll(resp.Body) + data, err := goio.ReadAll(resp.Body) if err != nil { - return nil, coreerr.E("Client.GetRaw", "forge: read response body", err) + return nil, core.E("Client.GetRaw", "forge: read response body", err) } return data, nil @@ -216,18 +538,18 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) (*http.Response, error) { url := c.baseURL + path - var bodyReader io.Reader + var bodyReader goio.Reader if body != nil { data, err := json.Marshal(body) if err != nil { - return nil, coreerr.E("Client.doJSON", "forge: marshal body", err) + return nil, core.E("Client.doJSON", "forge: marshal body", err) } bodyReader = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { - return nil, coreerr.E("Client.doJSON", "forge: create request", err) + return nil, core.E("Client.doJSON", "forge: create request", err) } req.Header.Set("Authorization", "token "+c.token) @@ -241,7 +563,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) resp, err := c.httpClient.Do(req) if err != nil { - return nil, coreerr.E("Client.doJSON", "forge: request "+method+" "+path, err) + return nil, core.E("Client.doJSON", "forge: request "+method+" "+path, err) } defer resp.Body.Close() @@ -253,7 +575,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) if out != nil && resp.StatusCode != http.StatusNoContent { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { - return nil, coreerr.E("Client.doJSON", "forge: decode response", err) + return nil, core.E("Client.doJSON", "forge: decode response", err) } } @@ -266,7 +588,7 @@ func (c *Client) parseError(resp *http.Response, path string) error { } // Read a bit of the body to see if we can get a message - data, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + data, _ := goio.ReadAll(goio.LimitReader(resp.Body, 1024)) _ = json.Unmarshal(data, &errBody) msg := errBody.Message diff --git a/client_test.go b/client_test.go index 1c67351..818383a 100644 --- a/client_test.go +++ b/client_test.go @@ -2,14 +2,16 @@ package forge import ( "context" - "encoding/json" - "errors" + "fmt" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" + + core "dappco.re/go/core" ) -func TestClient_Good_Get(t *testing.T) { +func TestClient_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -35,7 +37,7 @@ func TestClient_Good_Get(t *testing.T) { } } -func TestClient_Good_Post(t *testing.T) { +func TestClient_Post_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -62,7 +64,37 @@ func TestClient_Good_Post(t *testing.T) { } } -func TestClient_Good_Delete(t *testing.T) { +func TestClient_PostRaw_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if got := r.URL.Path; got != "/api/v1/markdown" { + t.Errorf("wrong path: %s", got) + } + w.Header().Set("X-RateLimit-Limit", "100") + w.Header().Set("X-RateLimit-Remaining", "98") + w.Header().Set("X-RateLimit-Reset", "1700000001") + w.Write([]byte("

Hello

")) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + body := map[string]string{"text": "Hello"} + got, err := c.PostRaw(context.Background(), "/api/v1/markdown", body) + if err != nil { + t.Fatal(err) + } + if string(got) != "

Hello

" { + t.Errorf("got body=%q", string(got)) + } + rl := c.RateLimit() + if rl.Limit != 100 || rl.Remaining != 98 || rl.Reset != 1700000001 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + +func TestClient_Delete_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -78,7 +110,36 @@ func TestClient_Good_Delete(t *testing.T) { } } -func TestClient_Bad_ServerError(t *testing.T) { +func TestClient_GetRaw_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if got := r.URL.Path; got != "/api/v1/signing-key.gpg" { + t.Errorf("wrong path: %s", got) + } + w.Header().Set("X-RateLimit-Limit", "60") + w.Header().Set("X-RateLimit-Remaining", "59") + w.Header().Set("X-RateLimit-Reset", "1700000002") + w.Write([]byte("key-data")) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + got, err := c.GetRaw(context.Background(), "/api/v1/signing-key.gpg") + if err != nil { + t.Fatal(err) + } + if string(got) != "key-data" { + t.Errorf("got body=%q", string(got)) + } + rl := c.RateLimit() + if rl.Limit != 60 || rl.Remaining != 59 || rl.Reset != 1700000002 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + +func TestClient_ServerError_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "internal error"}) @@ -91,7 +152,7 @@ func TestClient_Bad_ServerError(t *testing.T) { t.Fatal("expected error") } var apiErr *APIError - if !errors.As(err, &apiErr) { + if !core.As(err, &apiErr) { t.Fatalf("expected APIError, got %T", err) } if apiErr.StatusCode != 500 { @@ -99,7 +160,7 @@ func TestClient_Bad_ServerError(t *testing.T) { } } -func TestClient_Bad_NotFound(t *testing.T) { +func TestClient_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) @@ -113,7 +174,7 @@ func TestClient_Bad_NotFound(t *testing.T) { } } -func TestClient_Good_ContextCancellation(t *testing.T) { +func TestClient_ContextCancellation_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) @@ -128,54 +189,117 @@ func TestClient_Good_ContextCancellation(t *testing.T) { } } -func TestClient_Good_Options(t *testing.T) { +func TestClient_Options_Good(t *testing.T) { c := NewClient("https://forge.lthn.ai", "tok", WithUserAgent("go-forge/1.0"), ) if c.userAgent != "go-forge/1.0" { t.Errorf("got user agent=%q", c.userAgent) } + if got := c.UserAgent(); got != "go-forge/1.0" { + t.Errorf("got UserAgent()=%q", got) + } +} + +func TestClient_HasToken_Good(t *testing.T) { + c := NewClient("https://forge.lthn.ai", "tok") + if !c.HasToken() { + t.Fatal("expected HasToken to report configured token") + } +} + +func TestClient_HasToken_Bad(t *testing.T) { + c := NewClient("https://forge.lthn.ai", "") + if c.HasToken() { + t.Fatal("expected HasToken to report missing token") + } +} + +func TestClient_NilSafeAccessors(t *testing.T) { + var c *Client + if got := c.BaseURL(); got != "" { + t.Fatalf("got BaseURL()=%q, want empty string", got) + } + if got := c.RateLimit(); got != (RateLimit{}) { + t.Fatalf("got RateLimit()=%#v, want zero value", got) + } + if got := c.UserAgent(); got != "" { + t.Fatalf("got UserAgent()=%q, want empty string", got) + } + if got := c.HTTPClient(); got != nil { + t.Fatal("expected HTTPClient() to return nil") + } + if got := c.HasToken(); got { + t.Fatal("expected HasToken() to report false") + } } -func TestClient_Good_WithHTTPClient(t *testing.T) { +func TestClient_WithHTTPClient_Good(t *testing.T) { custom := &http.Client{} c := NewClient("https://forge.lthn.ai", "tok", WithHTTPClient(custom)) if c.httpClient != custom { t.Error("expected custom HTTP client to be set") } + if got := c.HTTPClient(); got != custom { + t.Error("expected HTTPClient() to return the configured HTTP client") + } } -func TestAPIError_Good_Error(t *testing.T) { +func TestClient_String_Good(t *testing.T) { + c := NewClient("https://forge.lthn.ai", "tok", WithUserAgent("go-forge/1.0")) + got := fmt.Sprint(c) + want := `forge.Client{baseURL="https://forge.lthn.ai", token=set, userAgent="go-forge/1.0"}` + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := c.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", c); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestAPIError_Error_Good(t *testing.T) { e := &APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"} got := e.Error() want := "forge: /api/v1/repos/x/y 404: not found" if got != want { t.Errorf("got %q, want %q", got, want) } + if got := e.String(); got != want { + t.Errorf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(e); got != want { + t.Errorf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", e); got != want { + t.Errorf("got GoString=%q, want %q", got, want) + } } -func TestIsConflict_Good(t *testing.T) { +func TestIsConflict_Match_Good(t *testing.T) { err := &APIError{StatusCode: http.StatusConflict, Message: "conflict", URL: "/test"} if !IsConflict(err) { t.Error("expected IsConflict to return true for 409") } } -func TestIsConflict_Bad_NotConflict(t *testing.T) { +func TestIsConflict_NotConflict_Bad(t *testing.T) { err := &APIError{StatusCode: http.StatusNotFound, Message: "not found", URL: "/test"} if IsConflict(err) { t.Error("expected IsConflict to return false for 404") } } -func TestIsForbidden_Bad_NotForbidden(t *testing.T) { +func TestIsForbidden_NotForbidden_Bad(t *testing.T) { err := &APIError{StatusCode: http.StatusNotFound, Message: "not found", URL: "/test"} if IsForbidden(err) { t.Error("expected IsForbidden to return false for 404") } } -func TestClient_Good_RateLimit(t *testing.T) { +func TestClient_RateLimit_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-RateLimit-Limit", "100") w.Header().Set("X-RateLimit-Remaining", "99") @@ -202,7 +326,7 @@ func TestClient_Good_RateLimit(t *testing.T) { } } -func TestClient_Bad_Forbidden(t *testing.T) { +func TestClient_Forbidden_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{"message": "forbidden"}) @@ -216,7 +340,7 @@ func TestClient_Bad_Forbidden(t *testing.T) { } } -func TestClient_Bad_Conflict(t *testing.T) { +func TestClient_Conflict_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{"message": "already exists"}) diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index aea2da5..83f89b2 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -2,14 +2,15 @@ package main import ( "bytes" + "cmp" "maps" - "path/filepath" "slices" + "strconv" "strings" "text/template" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" ) // typeGrouping maps type name prefixes to output file names. @@ -110,7 +111,7 @@ func classifyType(name string) string { bestKey := "" bestGroup := "" for key, group := range typeGrouping { - if strings.HasPrefix(name, key) && len(key) > len(bestKey) { + if core.HasPrefix(name, key) && len(key) > len(bestKey) { bestKey = key bestGroup = group } @@ -122,10 +123,10 @@ func classifyType(name string) string { // Strip CRUD prefixes and Option suffix, then retry. base := name for _, prefix := range []string{"Create", "Edit", "Delete", "Update", "Add", "Submit", "Replace", "Set", "Transfer"} { - base = strings.TrimPrefix(base, prefix) + base = core.TrimPrefix(base, prefix) } - base = strings.TrimSuffix(base, "Option") - base = strings.TrimSuffix(base, "Options") + base = core.TrimSuffix(base, "Option") + base = core.TrimSuffix(base, "Options") if base != name && base != "" { if group, ok := typeGrouping[base]; ok { @@ -135,7 +136,7 @@ func classifyType(name string) string { bestKey = "" bestGroup = "" for key, group := range typeGrouping { - if strings.HasPrefix(base, key) && len(key) > len(bestKey) { + if core.HasPrefix(base, key) && len(key) > len(bestKey) { bestKey = key bestGroup = group } @@ -151,7 +152,7 @@ func classifyType(name string) string { // sanitiseLine collapses a multi-line string into a single line, // replacing newlines and consecutive whitespace with a single space. func sanitiseLine(s string) string { - return strings.Join(strings.Fields(s), " ") + return core.Join(" ", splitFields(s)...) } // enumConstName generates a Go constant name for an enum value. @@ -176,6 +177,12 @@ import "time" {{- if .Description}} // {{.Name}} — {{sanitise .Description}} {{- end}} +{{- if .Usage}} +// +// Usage: +// +// opts := {{.Usage}} +{{- end}} {{- if .IsEnum}} type {{.Name}} string @@ -184,6 +191,8 @@ const ( {{enumConstName $t.Name .}} {{$t.Name}} = "{{.}}" {{- end}} ) +{{- else if .IsAlias}} +type {{.Name}} {{.AliasType}} {{- else if (eq (len .Fields) 0)}} // {{.Name}} has no fields in the swagger spec. type {{.Name}} struct{} @@ -204,11 +213,18 @@ type templateData struct { } // Generate writes Go source files for the extracted types, grouped by logical domain. +// +// Usage: +// +// err := Generate(types, pairs, "types") +// _ = err func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { if err := coreio.Local.EnsureDir(outDir); err != nil { - return coreerr.E("Generate", "create output directory", err) + return core.E("Generate", "create output directory", err) } + populateUsageExamples(types) + // Group types by output file. groups := make(map[string][]*GoType) for _, gt := range types { @@ -219,7 +235,7 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { // Sort types within each group for deterministic output. for file := range groups { slices.SortFunc(groups[file], func(a, b *GoType) int { - return strings.Compare(a.Name, b.Name) + return cmp.Compare(a.Name, b.Name) }) } @@ -228,20 +244,158 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { slices.Sort(fileNames) for _, file := range fileNames { - outPath := filepath.Join(outDir, file+".go") + outPath := core.JoinPath(outDir, file+".go") if err := writeFile(outPath, groups[file]); err != nil { - return coreerr.E("Generate", "write "+outPath, err) + return core.E("Generate", "write "+outPath, err) } } return nil } +func populateUsageExamples(types map[string]*GoType) { + for _, gt := range types { + gt.Usage = usageExample(gt) + } +} + +func usageExample(gt *GoType) string { + switch { + case gt.IsEnum && len(gt.EnumValues) > 0: + return enumConstName(gt.Name, gt.EnumValues[0]) + case gt.IsAlias: + return gt.Name + "(" + exampleTypeExpression(gt.AliasType) + ")" + default: + example := exampleTypeLiteral(gt) + if example == "" { + example = gt.Name + "{}" + } + return example + } +} + +func exampleTypeLiteral(gt *GoType) string { + if len(gt.Fields) == 0 { + return gt.Name + "{}" + } + + field := chooseUsageField(gt.Fields) + if field.GoName == "" { + return gt.Name + "{}" + } + + return gt.Name + "{" + field.GoName + ": " + exampleValue(field) + "}" +} + +func exampleTypeExpression(typeName string) string { + switch { + case typeName == "string": + return strconv.Quote("example") + case typeName == "bool": + return "true" + case typeName == "int", typeName == "int32", typeName == "int64", typeName == "uint", typeName == "uint32", typeName == "uint64": + return "1" + case typeName == "float32", typeName == "float64": + return "1.0" + case typeName == "time.Time": + return "time.Now()" + case core.HasPrefix(typeName, "[]string"): + return "[]string{\"example\"}" + case core.HasPrefix(typeName, "[]int64"): + return "[]int64{1}" + case core.HasPrefix(typeName, "[]int"): + return "[]int{1}" + case core.HasPrefix(typeName, "map["): + return typeName + "{\"key\": \"value\"}" + default: + return typeName + "{}" + } +} + +func chooseUsageField(fields []GoField) GoField { + best := fields[0] + bestScore := usageFieldScore(best) + for _, field := range fields[1:] { + score := usageFieldScore(field) + if score < bestScore || (score == bestScore && field.GoName < best.GoName) { + best = field + bestScore = score + } + } + return best +} + +func usageFieldScore(field GoField) int { + score := 100 + if field.Required { + score -= 50 + } + switch { + case core.HasSuffix(field.GoType, "string"): + score -= 30 + case core.Contains(field.GoType, "time.Time"): + score -= 25 + case core.HasSuffix(field.GoType, "bool"): + score -= 20 + case core.Contains(field.GoType, "int"): + score -= 15 + case core.HasPrefix(field.GoType, "[]"): + score -= 10 + } + if core.Contains(field.GoName, "Name") || core.Contains(field.GoName, "Title") || core.Contains(field.GoName, "Body") || core.Contains(field.GoName, "Description") { + score -= 10 + } + return score +} + +func exampleValue(field GoField) string { + switch { + case core.HasPrefix(field.GoType, "*"): + return "&" + core.TrimPrefix(field.GoType, "*") + "{}" + case field.GoType == "string": + return exampleStringValue(field.GoName) + case field.GoType == "time.Time": + return "time.Now()" + case field.GoType == "bool": + return "true" + case core.HasSuffix(field.GoType, "int64"), core.HasSuffix(field.GoType, "int"), core.HasSuffix(field.GoType, "uint64"), core.HasSuffix(field.GoType, "uint"): + return "1" + case core.HasPrefix(field.GoType, "[]string"): + return "[]string{\"example\"}" + case core.HasPrefix(field.GoType, "[]int64"): + return "[]int64{1}" + case core.HasPrefix(field.GoType, "[]int"): + return "[]int{1}" + case core.HasPrefix(field.GoType, "map["): + return field.GoType + "{\"key\": \"value\"}" + default: + return "{}" + } +} + +func exampleStringValue(fieldName string) string { + switch { + case core.Contains(fieldName, "URL"): + return "\"https://example.com\"" + case core.Contains(fieldName, "Email"): + return "\"alice@example.com\"" + case core.Contains(fieldName, "Tag"): + return "\"v1.0.0\"" + case core.Contains(fieldName, "Branch"), core.Contains(fieldName, "Ref"): + return "\"main\"" + default: + return "\"example\"" + } +} + // writeFile renders and writes a single Go source file for the given types. func writeFile(path string, types []*GoType) error { needTime := slices.ContainsFunc(types, func(gt *GoType) bool { + if core.Contains(gt.AliasType, "time.Time") { + return true + } return slices.ContainsFunc(gt.Fields, func(f GoField) bool { - return strings.Contains(f.GoType, "time.Time") + return core.Contains(f.GoType, "time.Time") }) }) @@ -252,11 +406,12 @@ func writeFile(path string, types []*GoType) error { var buf bytes.Buffer if err := fileHeader.Execute(&buf, data); err != nil { - return coreerr.E("writeFile", "execute template", err) + return core.E("writeFile", "execute template", err) } - if err := coreio.Local.Write(path, buf.String()); err != nil { - return coreerr.E("writeFile", "write file", err) + content := strings.TrimRight(buf.String(), "\n") + "\n" + if err := coreio.Local.Write(path, content); err != nil { + return core.E("writeFile", "write file", err) } return nil diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 9f60e45..68ebf7f 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -1,15 +1,13 @@ package main import ( - "os" - "path/filepath" - "strings" "testing" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" ) -func TestGenerate_Good_CreatesFiles(t *testing.T) { +func TestGenerate_CreatesFiles_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -23,10 +21,10 @@ func TestGenerate_Good_CreatesFiles(t *testing.T) { t.Fatal(err) } - entries, _ := os.ReadDir(outDir) + entries, _ := coreio.Local.List(outDir) goFiles := 0 for _, e := range entries { - if strings.HasSuffix(e.Name(), ".go") { + if core.HasSuffix(e.Name(), ".go") { goFiles++ } } @@ -35,7 +33,7 @@ func TestGenerate_Good_CreatesFiles(t *testing.T) { } } -func TestGenerate_Good_ValidGoSyntax(t *testing.T) { +func TestGenerate_ValidGoSyntax_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -49,11 +47,11 @@ func TestGenerate_Good_ValidGoSyntax(t *testing.T) { t.Fatal(err) } - entries, _ := os.ReadDir(outDir) + entries, _ := coreio.Local.List(outDir) var content string for _, e := range entries { - if strings.HasSuffix(e.Name(), ".go") { - content, err = coreio.Local.Read(filepath.Join(outDir, e.Name())) + if core.HasSuffix(e.Name(), ".go") { + content, err = coreio.Local.Read(core.JoinPath(outDir, e.Name())) if err == nil { break } @@ -62,15 +60,15 @@ func TestGenerate_Good_ValidGoSyntax(t *testing.T) { if err != nil || content == "" { t.Fatal("could not read any generated file") } - if !strings.Contains(content, "package types") { + if !core.Contains(content, "package types") { t.Error("missing package declaration") } - if !strings.Contains(content, "// Code generated") { + if !core.Contains(content, "// Code generated") { t.Error("missing generated comment") } } -func TestGenerate_Good_RepositoryType(t *testing.T) { +func TestGenerate_RepositoryType_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -85,10 +83,10 @@ func TestGenerate_Good_RepositoryType(t *testing.T) { } var content string - entries, _ := os.ReadDir(outDir) + entries, _ := coreio.Local.List(outDir) for _, e := range entries { - data, _ := coreio.Local.Read(filepath.Join(outDir, e.Name())) - if strings.Contains(data, "type Repository struct") { + data, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(data, "type Repository struct") { content = data break } @@ -107,13 +105,13 @@ func TestGenerate_Good_RepositoryType(t *testing.T) { "`json:\"private,omitempty\"`", } for _, check := range checks { - if !strings.Contains(content, check) { + if !core.Contains(content, check) { t.Errorf("missing field with tag %s", check) } } } -func TestGenerate_Good_TimeImport(t *testing.T) { +func TestGenerate_TimeImport_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -127,11 +125,131 @@ func TestGenerate_Good_TimeImport(t *testing.T) { t.Fatal(err) } - entries, _ := os.ReadDir(outDir) + entries, _ := coreio.Local.List(outDir) for _, e := range entries { - content, _ := coreio.Local.Read(filepath.Join(outDir, e.Name())) - if strings.Contains(content, "time.Time") && !strings.Contains(content, "\"time\"") { + content, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(content, "time.Time") && !core.Contains(content, "\"time\"") { t.Errorf("file %s uses time.Time but doesn't import time", e.Name()) } } } + +func TestGenerate_AdditionalProperties_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + pairs := DetectCRUDPairs(spec) + + outDir := t.TempDir() + if err := Generate(types, pairs, outDir); err != nil { + t.Fatal(err) + } + + entries, _ := coreio.Local.List(outDir) + var hookContent string + var teamContent string + for _, e := range entries { + data, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(data, "type CreateHookOptionConfig") { + hookContent = data + } + if core.Contains(data, "UnitsMap map[string]string `json:\"units_map,omitempty\"`") { + teamContent = data + } + } + if hookContent == "" { + t.Fatal("CreateHookOptionConfig type not found in any generated file") + } + if !core.Contains(hookContent, "type CreateHookOptionConfig map[string]any") { + t.Fatalf("generated alias not found in file:\n%s", hookContent) + } + if teamContent == "" { + t.Fatal("typed units_map field not found in any generated file") + } +} + +func TestGenerate_UsageExamples_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + pairs := DetectCRUDPairs(spec) + + outDir := t.TempDir() + if err := Generate(types, pairs, outDir); err != nil { + t.Fatal(err) + } + + entries, _ := coreio.Local.List(outDir) + var content string + for _, e := range entries { + data, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(data, "type CreateIssueOption struct") { + content = data + break + } + } + if content == "" { + t.Fatal("CreateIssueOption type not found in any generated file") + } + if !core.Contains(content, "// Usage:") { + t.Fatalf("generated option type is missing usage documentation:\n%s", content) + } + if !core.Contains(content, "opts :=") { + t.Fatalf("generated usage example is missing assignment syntax:\n%s", content) + } +} + +func TestGenerate_UsageExamples_AllKinds_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + pairs := DetectCRUDPairs(spec) + + outDir := t.TempDir() + if err := Generate(types, pairs, outDir); err != nil { + t.Fatal(err) + } + + entries, _ := coreio.Local.List(outDir) + var content string + for _, e := range entries { + data, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(data, "type CommitStatusState string") { + content = data + break + } + } + if content == "" { + t.Fatal("CommitStatusState type not found in any generated file") + } + if !core.Contains(content, "type CommitStatusState string") { + t.Fatalf("CommitStatusState type not generated:\n%s", content) + } + if !core.Contains(content, "// Usage:") { + t.Fatalf("generated enum type is missing usage documentation:\n%s", content) + } + + content = "" + for _, e := range entries { + data, _ := coreio.Local.Read(core.JoinPath(outDir, e.Name())) + if core.Contains(data, "type CreateHookOptionConfig map[string]any") { + content = data + break + } + } + if content == "" { + t.Fatal("CreateHookOptionConfig type not found in any generated file") + } + if !core.Contains(content, "CreateHookOptionConfig(map[string]any{\"key\": \"value\"})") { + t.Fatalf("generated alias type is missing a valid usage example:\n%s", content) + } +} diff --git a/cmd/forgegen/helpers.go b/cmd/forgegen/helpers.go new file mode 100644 index 0000000..401e82a --- /dev/null +++ b/cmd/forgegen/helpers.go @@ -0,0 +1,41 @@ +package main + +import ( + "unicode" + + core "dappco.re/go/core" +) + +func splitFields(s string) []string { + return splitFunc(s, unicode.IsSpace) +} + +func splitSnakeKebab(s string) []string { + return splitFunc(s, func(r rune) bool { + return r == '_' || r == '-' + }) +} + +func splitFunc(s string, isDelimiter func(rune) bool) []string { + var parts []string + buf := core.NewBuilder() + + flush := func() { + if buf.Len() == 0 { + return + } + parts = append(parts, buf.String()) + buf.Reset() + } + + for _, r := range s { + if isDelimiter(r) { + flush() + continue + } + buf.WriteRune(r) + } + flush() + + return parts +} diff --git a/cmd/forgegen/main.go b/cmd/forgegen/main.go index 856331d..08f95a3 100644 --- a/cmd/forgegen/main.go +++ b/cmd/forgegen/main.go @@ -2,8 +2,9 @@ package main import ( "flag" - "fmt" "os" + + core "dappco.re/go/core" ) func main() { @@ -11,20 +12,26 @@ func main() { outDir := flag.String("out", "types", "output directory for generated types") flag.Parse() - spec, err := LoadSpec(*specPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + if err := run(*specPath, *outDir); err != nil { + core.Print(os.Stderr, "forgegen: %v", err) os.Exit(1) } +} + +func run(specPath, outDir string) error { + spec, err := LoadSpec(specPath) + if err != nil { + return core.E("forgegen.main", "load spec", err) + } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) - fmt.Printf("Loaded %d types, %d CRUD pairs\n", len(types), len(pairs)) - fmt.Printf("Output dir: %s\n", *outDir) + core.Print(nil, "Loaded %d types, %d CRUD pairs", len(types), len(pairs)) + core.Print(nil, "Output dir: %s", outDir) - if err := Generate(types, pairs, *outDir); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + if err := Generate(types, pairs, outDir); err != nil { + return core.E("forgegen.main", "generate types", err) } + return nil } diff --git a/cmd/forgegen/main_test.go b/cmd/forgegen/main_test.go new file mode 100644 index 0000000..426a10e --- /dev/null +++ b/cmd/forgegen/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" +) + +func TestMain_Run_Good(t *testing.T) { + outDir := t.TempDir() + if err := run("../../testdata/swagger.v1.json", outDir); err != nil { + t.Fatal(err) + } + + entries, err := coreio.Local.List(outDir) + if err != nil { + t.Fatal(err) + } + + goFiles := 0 + for _, e := range entries { + if core.HasSuffix(e.Name(), ".go") { + goFiles++ + } + } + if goFiles == 0 { + t.Fatal("no .go files generated by run") + } +} + +func TestMain_Run_Bad(t *testing.T) { + err := run("/does/not/exist/swagger.v1.json", t.TempDir()) + if err == nil { + t.Fatal("expected error for invalid spec path") + } + if !core.Contains(err.Error(), "load spec") { + t.Fatalf("got error %q, expected load spec context", err.Error()) + } +} diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index f32bb8e..a73a38f 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -1,16 +1,20 @@ package main import ( - "encoding/json" - "fmt" + "cmp" + json "github.com/goccy/go-json" "slices" - "strings" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" ) // Spec represents a Swagger 2.0 specification document. +// +// Usage: +// +// spec, err := LoadSpec("testdata/swagger.v1.json") +// _ = spec type Spec struct { Swagger string `json:"swagger"` Info SpecInfo `json:"info"` @@ -19,42 +23,70 @@ type Spec struct { } // SpecInfo holds metadata about the API specification. +// +// Usage: +// +// _ = SpecInfo{Title: "Forgejo API", Version: "1.0"} type SpecInfo struct { Title string `json:"title"` Version string `json:"version"` } // SchemaDefinition represents a single type definition in the swagger spec. +// +// Usage: +// +// _ = SchemaDefinition{Type: "object"} type SchemaDefinition struct { - Description string `json:"description"` - Type string `json:"type"` - Properties map[string]SchemaProperty `json:"properties"` - Required []string `json:"required"` - Enum []any `json:"enum"` - XGoName string `json:"x-go-name"` + Description string `json:"description"` + Format string `json:"format"` + Ref string `json:"$ref"` + Items *SchemaProperty `json:"items"` + Type string `json:"type"` + Properties map[string]SchemaProperty `json:"properties"` + Required []string `json:"required"` + Enum []any `json:"enum"` + AdditionalProperties *SchemaProperty `json:"additionalProperties"` + XGoName string `json:"x-go-name"` } // SchemaProperty represents a single property within a schema definition. +// +// Usage: +// +// _ = SchemaProperty{Type: "string"} type SchemaProperty struct { - Type string `json:"type"` - Format string `json:"format"` - Description string `json:"description"` - Ref string `json:"$ref"` - Items *SchemaProperty `json:"items"` - Enum []any `json:"enum"` - XGoName string `json:"x-go-name"` + Type string `json:"type"` + Format string `json:"format"` + Description string `json:"description"` + Ref string `json:"$ref"` + Items *SchemaProperty `json:"items"` + Enum []any `json:"enum"` + AdditionalProperties *SchemaProperty `json:"additionalProperties"` + XGoName string `json:"x-go-name"` } // GoType is the intermediate representation for a Go type to be generated. +// +// Usage: +// +// _ = GoType{Name: "Repository"} type GoType struct { Name string Description string + Usage string Fields []GoField IsEnum bool EnumValues []string + IsAlias bool + AliasType string } // GoField is the intermediate representation for a single struct field. +// +// Usage: +// +// _ = GoField{GoName: "ID", GoType: "int64"} type GoField struct { GoName string GoType string @@ -64,6 +96,10 @@ type GoField struct { } // CRUDPair groups a base type with its corresponding Create and Edit option types. +// +// Usage: +// +// _ = CRUDPair{Base: "Repository", Create: "CreateRepoOption", Edit: "EditRepoOption"} type CRUDPair struct { Base string Create string @@ -71,19 +107,29 @@ type CRUDPair struct { } // LoadSpec reads and parses a Swagger 2.0 JSON file from the given path. +// +// Usage: +// +// spec, err := LoadSpec("testdata/swagger.v1.json") +// _ = spec func LoadSpec(path string) (*Spec, error) { content, err := coreio.Local.Read(path) if err != nil { - return nil, coreerr.E("LoadSpec", "read spec", err) + return nil, core.E("LoadSpec", "read spec", err) } var spec Spec if err := json.Unmarshal([]byte(content), &spec); err != nil { - return nil, coreerr.E("LoadSpec", "parse spec", err) + return nil, core.E("LoadSpec", "parse spec", err) } return &spec, nil } // ExtractTypes converts all swagger definitions into Go type intermediate representations. +// +// Usage: +// +// types := ExtractTypes(spec) +// _ = types["Repository"] func ExtractTypes(spec *Spec) map[string]*GoType { result := make(map[string]*GoType) for name, def := range spec.Definitions { @@ -91,12 +137,19 @@ func ExtractTypes(spec *Spec) map[string]*GoType { if len(def.Enum) > 0 { gt.IsEnum = true for _, v := range def.Enum { - gt.EnumValues = append(gt.EnumValues, fmt.Sprintf("%v", v)) + gt.EnumValues = append(gt.EnumValues, core.Sprint(v)) } slices.Sort(gt.EnumValues) result[name] = gt continue } + + if aliasType, ok := definitionAliasType(def, spec.Definitions); ok { + gt.IsAlias = true + gt.AliasType = aliasType + result[name] = gt + continue + } required := make(map[string]bool) for _, r := range def.Required { required[r] = true @@ -108,7 +161,7 @@ func ExtractTypes(spec *Spec) map[string]*GoType { } gf := GoField{ GoName: goName, - GoType: resolveGoType(prop), + GoType: resolveGoType(prop, spec.Definitions), JSONName: fieldName, Comment: prop.Description, Required: required[fieldName], @@ -116,24 +169,69 @@ func ExtractTypes(spec *Spec) map[string]*GoType { gt.Fields = append(gt.Fields, gf) } slices.SortFunc(gt.Fields, func(a, b GoField) int { - return strings.Compare(a.GoName, b.GoName) + return cmp.Compare(a.GoName, b.GoName) }) result[name] = gt } return result } +func definitionAliasType(def SchemaDefinition, defs map[string]SchemaDefinition) (string, bool) { + if def.Ref != "" { + return refName(def.Ref), true + } + + switch def.Type { + case "string": + return "string", true + case "integer": + switch def.Format { + case "int64": + return "int64", true + case "int32": + return "int32", true + default: + return "int", true + } + case "number": + switch def.Format { + case "float": + return "float32", true + default: + return "float64", true + } + case "boolean": + return "bool", true + case "array": + if def.Items != nil { + return "[]" + resolveGoType(*def.Items, defs), true + } + return "[]any", true + case "object": + if def.AdditionalProperties != nil { + return resolveMapType(*def.AdditionalProperties, defs), true + } + } + + return "", false +} + // DetectCRUDPairs finds Create*Option / Edit*Option pairs in the swagger definitions // and maps them back to the base type name. +// +// Usage: +// +// pairs := DetectCRUDPairs(spec) +// _ = pairs func DetectCRUDPairs(spec *Spec) []CRUDPair { var pairs []CRUDPair for name := range spec.Definitions { - if !strings.HasPrefix(name, "Create") || !strings.HasSuffix(name, "Option") { + if !core.HasPrefix(name, "Create") || !core.HasSuffix(name, "Option") { continue } - inner := strings.TrimPrefix(name, "Create") - inner = strings.TrimSuffix(inner, "Option") - editName := "Edit" + inner + "Option" + inner := core.TrimPrefix(name, "Create") + inner = core.TrimSuffix(inner, "Option") + editName := core.Concat("Edit", inner, "Option") pair := CRUDPair{Base: inner, Create: name} if _, ok := spec.Definitions[editName]; ok { pair.Edit = editName @@ -141,16 +239,15 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { pairs = append(pairs, pair) } slices.SortFunc(pairs, func(a, b CRUDPair) int { - return strings.Compare(a.Base, b.Base) + return cmp.Compare(a.Base, b.Base) }) return pairs } // resolveGoType maps a swagger schema property to a Go type string. -func resolveGoType(prop SchemaProperty) string { +func resolveGoType(prop SchemaProperty, defs map[string]SchemaDefinition) string { if prop.Ref != "" { - parts := strings.Split(prop.Ref, "/") - return "*" + parts[len(parts)-1] + return refGoType(prop.Ref, defs) } switch prop.Type { case "string": @@ -182,33 +279,74 @@ func resolveGoType(prop SchemaProperty) string { return "bool" case "array": if prop.Items != nil { - return "[]" + resolveGoType(*prop.Items) + return "[]" + resolveGoType(*prop.Items, defs) } return "[]any" case "object": - return "map[string]any" + return resolveMapType(prop, defs) default: return "any" } } +// resolveMapType maps a swagger object with additionalProperties to a Go map type. +func resolveMapType(prop SchemaProperty, defs map[string]SchemaDefinition) string { + valueType := "any" + if prop.AdditionalProperties != nil { + valueType = resolveGoType(*prop.AdditionalProperties, defs) + } + return "map[string]" + valueType +} + +func refName(ref string) string { + parts := core.Split(ref, "/") + return parts[len(parts)-1] +} + +func refGoType(ref string, defs map[string]SchemaDefinition) string { + name := refName(ref) + def, ok := defs[name] + if !ok { + return "*" + name + } + if definitionNeedsPointer(def) { + return "*" + name + } + return name +} + +func definitionNeedsPointer(def SchemaDefinition) bool { + if len(def.Enum) > 0 { + return false + } + if def.Ref != "" { + return false + } + switch def.Type { + case "string", "integer", "number", "boolean", "array": + return false + case "object": + return true + default: + return false + } +} + // pascalCase converts a snake_case or kebab-case string to PascalCase, // with common acronyms kept uppercase. func pascalCase(s string) string { var parts []string - for p := range strings.FieldsFuncSeq(s, func(r rune) bool { - return r == '_' || r == '-' - }) { + for _, p := range splitSnakeKebab(s) { if len(p) == 0 { continue } - upper := strings.ToUpper(p) + upper := core.Upper(p) switch upper { case "ID", "URL", "HTML", "SSH", "HTTP", "HTTPS", "API", "URI", "GPG", "IP", "CSS", "JS": parts = append(parts, upper) default: - parts = append(parts, strings.ToUpper(p[:1])+p[1:]) + parts = append(parts, core.Concat(core.Upper(p[:1]), p[1:])) } } - return strings.Join(parts, "") + return core.Concat(parts...) } diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index 2607268..e88da5f 100644 --- a/cmd/forgegen/parser_test.go +++ b/cmd/forgegen/parser_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestParser_Good_LoadSpec(t *testing.T) { +func TestParser_LoadSpec_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -17,7 +17,7 @@ func TestParser_Good_LoadSpec(t *testing.T) { } } -func TestParser_Good_ExtractTypes(t *testing.T) { +func TestParser_ExtractTypes_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -38,7 +38,7 @@ func TestParser_Good_ExtractTypes(t *testing.T) { } } -func TestParser_Good_FieldTypes(t *testing.T) { +func TestParser_FieldTypes_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -70,11 +70,15 @@ func TestParser_Good_FieldTypes(t *testing.T) { if f.GoType != "*User" { t.Errorf("owner: got %q, want *User", f.GoType) } + case "units_map": + if f.GoType != "map[string]string" { + t.Errorf("units_map: got %q, want map[string]string", f.GoType) + } } } } -func TestParser_Good_DetectCreateEditPairs(t *testing.T) { +func TestParser_DetectCreateEditPairs_Good(t *testing.T) { spec, err := LoadSpec("../../testdata/swagger.v1.json") if err != nil { t.Fatal(err) @@ -101,3 +105,65 @@ func TestParser_Good_DetectCreateEditPairs(t *testing.T) { t.Fatal("Repo pair not found") } } + +func TestParser_AdditionalPropertiesAlias_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + alias, ok := types["CreateHookOptionConfig"] + if !ok { + t.Fatal("CreateHookOptionConfig type not found") + } + if !alias.IsAlias { + t.Fatal("expected CreateHookOptionConfig to be emitted as an alias") + } + if alias.AliasType != "map[string]any" { + t.Fatalf("got alias type %q, want map[string]any", alias.AliasType) + } +} + +func TestParser_PrimitiveAndCollectionAliases_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + + cases := []struct { + name string + wantType string + }{ + {name: "CommitStatusState", wantType: "string"}, + {name: "IssueFormFieldType", wantType: "string"}, + {name: "IssueFormFieldVisible", wantType: "string"}, + {name: "NotifySubjectType", wantType: "string"}, + {name: "ReviewStateType", wantType: "string"}, + {name: "StateType", wantType: "string"}, + {name: "TimeStamp", wantType: "int64"}, + {name: "IssueTemplateLabels", wantType: "[]string"}, + {name: "QuotaGroupList", wantType: "[]*QuotaGroup"}, + {name: "QuotaUsedArtifactList", wantType: "[]*QuotaUsedArtifact"}, + {name: "QuotaUsedAttachmentList", wantType: "[]*QuotaUsedAttachment"}, + {name: "QuotaUsedPackageList", wantType: "[]*QuotaUsedPackage"}, + {name: "CreatePullReviewCommentOptions", wantType: "CreatePullReviewComment"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gt, ok := types[tc.name] + if !ok { + t.Fatalf("type %q not found", tc.name) + } + if !gt.IsAlias { + t.Fatalf("type %q should be emitted as an alias", tc.name) + } + if gt.AliasType != tc.wantType { + t.Fatalf("type %q: got alias %q, want %q", tc.name, gt.AliasType, tc.wantType) + } + }) + } +} diff --git a/commits.go b/commits.go index 1c38c82..8735b46 100644 --- a/commits.go +++ b/commits.go @@ -2,25 +2,148 @@ package forge import ( "context" - "fmt" + "iter" + "strconv" "dappco.re/go/core/forge/types" ) // CommitService handles commit-related operations such as commit statuses // and git notes. -// No Resource embedding — heterogeneous endpoints across status and note paths. +// No Resource embedding — collection and item commit paths differ, and the +// remaining endpoints are heterogeneous across status and note paths. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Commits.GetCombinedStatus(ctx, "core", "go-forge", "main") type CommitService struct { client *Client } +// CommitListOptions controls filtering for repository commit listings. +// +// Usage: +// +// stat := false +// opts := forge.CommitListOptions{Sha: "main", Stat: &stat} +type CommitListOptions struct { + Sha string + Path string + Stat *bool + Verification *bool + Files *bool + Not string +} + +// String returns a safe summary of the commit list filters. +func (o CommitListOptions) String() string { + return optionString("forge.CommitListOptions", + "sha", o.Sha, + "path", o.Path, + "stat", o.Stat, + "verification", o.Verification, + "files", o.Files, + "not", o.Not, + ) +} + +// GoString returns a safe Go-syntax summary of the commit list filters. +func (o CommitListOptions) GoString() string { return o.String() } + +func (o CommitListOptions) queryParams() map[string]string { + query := make(map[string]string, 6) + if o.Sha != "" { + query["sha"] = o.Sha + } + if o.Path != "" { + query["path"] = o.Path + } + if o.Stat != nil { + query["stat"] = strconv.FormatBool(*o.Stat) + } + if o.Verification != nil { + query["verification"] = strconv.FormatBool(*o.Verification) + } + if o.Files != nil { + query["files"] = strconv.FormatBool(*o.Files) + } + if o.Not != "" { + query["not"] = o.Not + } + if len(query) == 0 { + return nil + } + return query +} + +const ( + commitCollectionPath = "/api/v1/repos/{owner}/{repo}/commits" + commitItemPath = "/api/v1/repos/{owner}/{repo}/git/commits/{sha}" +) + func newCommitService(c *Client) *CommitService { return &CommitService{client: c} } +// List returns a single page of commits for a repository. +func (s *CommitService) List(ctx context.Context, params Params, opts ListOptions, filters ...CommitListOptions) (*PagedResult[types.Commit], error) { + return ListPage[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...), opts) +} + +// ListAll returns all commits for a repository. +func (s *CommitService) ListAll(ctx context.Context, params Params, filters ...CommitListOptions) ([]types.Commit, error) { + return ListAll[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...)) +} + +// Iter returns an iterator over all commits for a repository. +func (s *CommitService) Iter(ctx context.Context, params Params, filters ...CommitListOptions) iter.Seq2[types.Commit, error] { + return ListIter[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...)) +} + +// Get returns a single commit by SHA or ref. +func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, error) { + var out types.Commit + if err := s.client.Get(ctx, ResolvePath(commitItemPath, params), &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetDiffOrPatch returns a commit diff or patch as raw bytes. +func (s *CommitService) GetDiffOrPatch(ctx context.Context, owner, repo, sha, diffType string) ([]byte, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/commits/{sha}.{diffType}", pathParams("owner", owner, "repo", repo, "sha", sha, "diffType", diffType)) + return s.client.GetRaw(ctx, path) +} + +// GetPullRequest returns the pull request associated with a commit SHA. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Commits.GetPullRequest(ctx, "core", "go-forge", "abc123") +func (s *CommitService) GetPullRequest(ctx context.Context, owner, repo, sha string) (*types.PullRequest, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/commits/{sha}/pull", pathParams("owner", owner, "repo", repo, "sha", sha)) + var out types.PullRequest + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + // GetCombinedStatus returns the combined status for a given ref (branch, tag, or SHA). func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*types.CombinedStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, ref) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/statuses/{ref}", pathParams("owner", owner, "repo", repo, "ref", ref)) + var out types.CombinedStatus + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetCombinedStatusByRef returns the combined status for a given commit reference. +func (s *CommitService) GetCombinedStatusByRef(ctx context.Context, owner, repo, ref string) (*types.CombinedStatus, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/commits/{ref}/status", pathParams("owner", owner, "repo", repo, "ref", ref)) var out types.CombinedStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -30,7 +153,7 @@ func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref // ListStatuses returns all commit statuses for a given ref. func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref string) ([]types.CommitStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/statuses", owner, repo, ref) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/commits/{ref}/statuses", pathParams("owner", owner, "repo", repo, "ref", ref)) var out []types.CommitStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -38,9 +161,25 @@ func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref strin return out, nil } +// IterStatuses returns an iterator over all commit statuses for a given ref. +func (s *CommitService) IterStatuses(ctx context.Context, owner, repo, ref string) iter.Seq2[types.CommitStatus, error] { + return func(yield func(types.CommitStatus, error) bool) { + statuses, err := s.ListStatuses(ctx, owner, repo, ref) + if err != nil { + yield(*new(types.CommitStatus), err) + return + } + for _, status := range statuses { + if !yield(status, nil) { + return + } + } + } +} + // CreateStatus creates a new commit status for the given SHA. func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha string, opts *types.CreateStatusOption) (*types.CommitStatus, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, sha) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/statuses/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) var out types.CommitStatus if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -50,10 +189,39 @@ func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha strin // GetNote returns the git note for a given commit SHA. func (s *CommitService) GetNote(ctx context.Context, owner, repo, sha string) (*types.Note, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/git/notes/%s", owner, repo, sha) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/notes/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) var out types.Note if err := s.client.Get(ctx, path, &out); err != nil { return nil, err } return &out, nil } + +// SetNote creates or updates the git note for a given commit SHA. +func (s *CommitService) SetNote(ctx context.Context, owner, repo, sha, message string) (*types.Note, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/notes/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) + var out types.Note + if err := s.client.Post(ctx, path, types.NoteOptions{Message: message}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteNote removes the git note for a given commit SHA. +func (s *CommitService) DeleteNote(ctx context.Context, owner, repo, sha string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/notes/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) + return s.client.Delete(ctx, path) +} + +func commitListQuery(filters ...CommitListOptions) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/commits_extra_test.go b/commits_extra_test.go new file mode 100644 index 0000000..379124b --- /dev/null +++ b/commits_extra_test.go @@ -0,0 +1,36 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestCommitService_GetCombinedStatusByRef_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits/main/status" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.CombinedStatus{ + SHA: "main", + TotalCount: 3, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + status, err := f.Commits.GetCombinedStatusByRef(context.Background(), "core", "go-forge", "main") + if err != nil { + t.Fatal(err) + } + if status.SHA != "main" || status.TotalCount != 3 { + t.Fatalf("got %#v", status) + } +} diff --git a/commits_test.go b/commits_test.go index 4c44a0f..0de4c6a 100644 --- a/commits_test.go +++ b/commits_test.go @@ -2,7 +2,8 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" + "io" "net/http" "net/http/httptest" "testing" @@ -10,7 +11,199 @@ import ( "dappco.re/go/core/forge/types" ) -func TestCommitService_Good_ListStatuses(t *testing.T) { +func TestCommitService_List_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if r.URL.Query().Get("page") != "1" { + t.Errorf("got page=%q, want %q", r.URL.Query().Get("page"), "1") + } + if r.URL.Query().Get("limit") != "50" { + t.Errorf("got limit=%q, want %q", r.URL.Query().Get("limit"), "50") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Commit{ + { + SHA: "abc123", + Commit: &types.RepoCommit{ + Message: "first commit", + }, + }, + { + SHA: "def456", + Commit: &types.RepoCommit{ + Message: "second commit", + }, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Commits.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, DefaultList) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 { + t.Fatalf("got %d items, want 2", len(result.Items)) + } + if result.Items[0].SHA != "abc123" { + t.Errorf("got sha=%q, want %q", result.Items[0].SHA, "abc123") + } + if result.Items[1].Commit == nil { + t.Fatal("expected commit payload, got nil") + } + if result.Items[1].Commit.Message != "second commit" { + t.Errorf("got message=%q, want %q", result.Items[1].Commit.Message, "second commit") + } +} + +func TestCommitService_ListFiltered_Good(t *testing.T) { + stat := false + verification := false + files := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + want := map[string]string{ + "sha": "main", + "path": "docs", + "stat": "false", + "verification": "false", + "files": "false", + "not": "deadbeef", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Commit{{SHA: "abc123"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commits, err := f.Commits.ListAll(context.Background(), Params{"owner": "core", "repo": "go-forge"}, CommitListOptions{ + Sha: "main", + Path: "docs", + Stat: &stat, + Verification: &verification, + Files: &files, + Not: "deadbeef", + }) + if err != nil { + t.Fatal(err) + } + if len(commits) != 1 || commits[0].SHA != "abc123" { + t.Fatalf("got %#v", commits) + } +} + +func TestCommitService_Get_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/git/commits/abc123" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Commit{ + SHA: "abc123", + HTMLURL: "https://forge.example/core/go-forge/commit/abc123", + Commit: &types.RepoCommit{ + Message: "initial import", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commit, err := f.Commits.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "sha": "abc123"}) + if err != nil { + t.Fatal(err) + } + if commit.SHA != "abc123" { + t.Errorf("got sha=%q, want %q", commit.SHA, "abc123") + } + if commit.Commit == nil { + t.Fatal("expected commit payload, got nil") + } + if commit.Commit.Message != "initial import" { + t.Errorf("got message=%q, want %q", commit.Commit.Message, "initial import") + } +} + +func TestCommitService_GetDiffOrPatch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/git/commits/abc123.diff" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, "diff --git a/README.md b/README.md") + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + data, err := f.Commits.GetDiffOrPatch(context.Background(), "core", "go-forge", "abc123", "diff") + if err != nil { + t.Fatal(err) + } + if string(data) != "diff --git a/README.md b/README.md" { + t.Fatalf("got body=%q", string(data)) + } +} + +func TestCommitService_GetPullRequest_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits/abc123/pull" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.PullRequest{ + ID: 17, + Index: 9, + Title: "Add commit-linked pull request", + Head: &types.PRBranchInfo{ + Ref: "feature/commit-link", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Commits.GetPullRequest(context.Background(), "core", "go-forge", "abc123") + if err != nil { + t.Fatal(err) + } + if pr.ID != 17 { + t.Errorf("got id=%d, want 17", pr.ID) + } + if pr.Index != 9 { + t.Errorf("got index=%d, want 9", pr.Index) + } + if pr.Head == nil || pr.Head.Ref != "feature/commit-link" { + t.Fatalf("unexpected head branch info: %+v", pr.Head) + } +} + +func TestCommitService_ListStatuses_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -41,7 +234,40 @@ func TestCommitService_Good_ListStatuses(t *testing.T) { } } -func TestCommitService_Good_CreateStatus(t *testing.T) { +func TestCommitService_IterStatuses_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits/abc123/statuses" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.CommitStatus{ + {ID: 1, Context: "ci/build"}, + {ID: 2, Context: "ci/test"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for status, err := range f.Commits.IterStatuses(context.Background(), "core", "go-forge", "abc123") { + if err != nil { + t.Fatal(err) + } + got = append(got, status.Context) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 2 || got[0] != "ci/build" || got[1] != "ci/test" { + t.Fatalf("got %#v", got) + } +} + +func TestCommitService_CreateStatus_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -84,7 +310,7 @@ func TestCommitService_Good_CreateStatus(t *testing.T) { } } -func TestCommitService_Good_GetNote(t *testing.T) { +func TestCommitService_GetNote_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -114,7 +340,62 @@ func TestCommitService_Good_GetNote(t *testing.T) { } } -func TestCommitService_Good_GetCombinedStatus(t *testing.T) { +func TestCommitService_SetNote_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/git/notes/abc123" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.NoteOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Message != "reviewed and approved" { + t.Errorf("got message=%q, want %q", opts.Message, "reviewed and approved") + } + json.NewEncoder(w).Encode(types.Note{ + Message: "reviewed and approved", + Commit: &types.Commit{ + SHA: "abc123", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + note, err := f.Commits.SetNote(context.Background(), "core", "go-forge", "abc123", "reviewed and approved") + if err != nil { + t.Fatal(err) + } + if note.Message != "reviewed and approved" { + t.Errorf("got message=%q, want %q", note.Message, "reviewed and approved") + } + if note.Commit.SHA != "abc123" { + t.Errorf("got commit sha=%q, want %q", note.Commit.SHA, "abc123") + } +} + +func TestCommitService_DeleteNote_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/git/notes/abc123" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Commits.DeleteNote(context.Background(), "core", "go-forge", "abc123"); err != nil { + t.Fatal(err) + } +} + +func TestCommitService_GetCombinedStatus_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -149,7 +430,7 @@ func TestCommitService_Good_GetCombinedStatus(t *testing.T) { } } -func TestCommitService_Bad_NotFound(t *testing.T) { +func TestCommitService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) diff --git a/config.go b/config.go index b4ad6fa..6da3f02 100644 --- a/config.go +++ b/config.go @@ -1,26 +1,108 @@ package forge import ( + "encoding/json" "os" + "path/filepath" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" ) const ( // DefaultURL is the fallback Forgejo instance URL when neither flag nor // environment variable is set. + // + // Usage: + // cfgURL, _, _ := forge.ResolveConfig("", "") + // _ = cfgURL == forge.DefaultURL DefaultURL = "http://localhost:3000" ) +const defaultConfigPath = ".config/forge/config.json" + +type configFile struct { + URL string `json:"url"` + Token string `json:"token"` +} + +// ConfigPath returns the default config file path used by SaveConfig and +// ResolveConfig. +// +// Usage: +// +// path, err := forge.ConfigPath() +// _ = path +func ConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", core.E("ConfigPath", "forge: resolve home directory", err) + } + return filepath.Join(home, defaultConfigPath), nil +} + +func readConfigFile() (url, token string, err error) { + path, err := ConfigPath() + if err != nil { + return "", "", err + } + + data, err := coreio.Local.Read(path) + if err != nil { + if os.IsNotExist(err) { + return "", "", nil + } + return "", "", core.E("ResolveConfig", "forge: read config file", err) + } + + var cfg configFile + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return "", "", core.E("ResolveConfig", "forge: decode config file", err) + } + return cfg.URL, cfg.Token, nil +} + +// SaveConfig persists the Forgejo URL and API token to the default config file. +// It creates the parent directory if it does not already exist. +// +// Usage: +// +// _ = forge.SaveConfig("https://forge.example.com", "token") +func SaveConfig(url, token string) error { + path, err := ConfigPath() + if err != nil { + return err + } + if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { + return core.E("SaveConfig", "forge: create config directory", err) + } + payload, err := json.MarshalIndent(configFile{URL: url, Token: token}, "", " ") + if err != nil { + return core.E("SaveConfig", "forge: encode config file", err) + } + return coreio.Local.WriteMode(path, string(payload), 0600) +} + // ResolveConfig resolves the Forgejo URL and API token from flags, environment -// variables, and built-in defaults. Priority order: flags > env > defaults. +// variables, config file, and built-in defaults. Priority order: +// flags > env > config file > defaults. // // Environment variables: // - FORGE_URL — base URL of the Forgejo instance // - FORGE_TOKEN — API token for authentication +// +// Usage: +// +// url, token, err := forge.ResolveConfig("", "") +// _ = url +// _ = token func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - url = os.Getenv("FORGE_URL") - token = os.Getenv("FORGE_TOKEN") + if envURL, ok := os.LookupEnv("FORGE_URL"); ok && envURL != "" { + url = envURL + } + if envToken, ok := os.LookupEnv("FORGE_TOKEN"); ok && envToken != "" { + token = envToken + } if flagURL != "" { url = flagURL @@ -28,21 +110,49 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { if flagToken != "" { token = flagToken } + if url == "" || token == "" { + fileURL, fileToken, fileErr := readConfigFile() + if fileErr != nil { + return "", "", fileErr + } + if url == "" { + url = fileURL + } + if token == "" { + token = fileToken + } + } if url == "" { url = DefaultURL } return url, token, nil } +// NewFromConfig creates a new Forge client using resolved configuration. +// +// Usage: +// +// f, err := forge.NewFromConfig("", "") +// _ = f +func NewFromConfig(flagURL, flagToken string, opts ...Option) (*Forge, error) { + return NewForgeFromConfig(flagURL, flagToken, opts...) +} + // NewForgeFromConfig creates a new Forge client using resolved configuration. -// It returns an error if no API token is available from flags or environment. +// It returns an error if no API token is available from flags, environment, +// or the saved config file. +// +// Usage: +// +// f, err := forge.NewForgeFromConfig("", "") +// _ = f func NewForgeFromConfig(flagURL, flagToken string, opts ...Option) (*Forge, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { return nil, err } if token == "" { - return nil, coreerr.E("NewForgeFromConfig", "forge: no API token configured (set FORGE_TOKEN or pass --token)", nil) + return nil, core.E("NewForgeFromConfig", "forge: no API token configured (set FORGE_TOKEN or pass --token)", nil) } return NewForge(url, token, opts...), nil } diff --git a/config_test.go b/config_test.go index 2b7c58f..5d35f47 100644 --- a/config_test.go +++ b/config_test.go @@ -1,11 +1,15 @@ package forge import ( - "os" + "encoding/json" + "path/filepath" "testing" + + coreio "dappco.re/go/core/io" ) -func TestResolveConfig_Good_EnvOverrides(t *testing.T) { +func TestResolveConfig_EnvOverrides_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "https://forge.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -21,7 +25,8 @@ func TestResolveConfig_Good_EnvOverrides(t *testing.T) { } } -func TestResolveConfig_Good_FlagOverridesEnv(t *testing.T) { +func TestResolveConfig_FlagOverridesEnv_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "https://env.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -37,9 +42,10 @@ func TestResolveConfig_Good_FlagOverridesEnv(t *testing.T) { } } -func TestResolveConfig_Good_DefaultURL(t *testing.T) { - os.Unsetenv("FORGE_URL") - os.Unsetenv("FORGE_TOKEN") +func TestResolveConfig_DefaultURL_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") url, _, err := ResolveConfig("", "") if err != nil { @@ -50,12 +56,150 @@ func TestResolveConfig_Good_DefaultURL(t *testing.T) { } } -func TestNewForgeFromConfig_Bad_NoToken(t *testing.T) { - os.Unsetenv("FORGE_URL") - os.Unsetenv("FORGE_TOKEN") +func TestResolveConfig_ConfigFile_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") + + cfgPath := filepath.Join(home, ".config", "forge", "config.json") + if err := coreio.Local.EnsureDir(filepath.Dir(cfgPath)); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]string{ + "url": "https://file.example.com", + "token": "file-token", + }) + if err != nil { + t.Fatal(err) + } + if err := coreio.Local.WriteMode(cfgPath, string(data), 0600); err != nil { + t.Fatal(err) + } + + url, token, err := ResolveConfig("", "") + if err != nil { + t.Fatal(err) + } + if url != "https://file.example.com" { + t.Errorf("got url=%q", url) + } + if token != "file-token" { + t.Errorf("got token=%q", token) + } +} + +func TestResolveConfig_EnvOverridesConfig_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("FORGE_URL", "https://env.example.com") + t.Setenv("FORGE_TOKEN", "env-token") + + if err := SaveConfig("https://file.example.com", "file-token"); err != nil { + t.Fatal(err) + } + + url, token, err := ResolveConfig("", "") + if err != nil { + t.Fatal(err) + } + if url != "https://env.example.com" { + t.Errorf("got url=%q", url) + } + if token != "env-token" { + t.Errorf("got token=%q", token) + } +} + +func TestResolveConfig_FlagOverridesBrokenConfig_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") + + cfgPath := filepath.Join(home, ".config", "forge", "config.json") + if err := coreio.Local.EnsureDir(filepath.Dir(cfgPath)); err != nil { + t.Fatal(err) + } + if err := coreio.Local.WriteMode(cfgPath, "{not-json", 0600); err != nil { + t.Fatal(err) + } + + url, token, err := ResolveConfig("https://flag.example.com", "flag-token") + if err != nil { + t.Fatal(err) + } + if url != "https://flag.example.com" { + t.Errorf("got url=%q", url) + } + if token != "flag-token" { + t.Errorf("got token=%q", token) + } +} + +func TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") _, err := NewForgeFromConfig("", "") if err == nil { t.Fatal("expected error for missing token") } } + +func TestNewFromConfig_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_URL", "https://forge.example.com") + t.Setenv("FORGE_TOKEN", "env-token") + + f, err := NewFromConfig("", "") + if err != nil { + t.Fatal(err) + } + if f == nil { + t.Fatal("expected forge client") + } + if got := f.BaseURL(); got != "https://forge.example.com" { + t.Errorf("got baseURL=%q", got) + } +} + +func TestSaveConfig_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := SaveConfig("https://file.example.com", "file-token"); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(home, ".config", "forge", "config.json") + data, err := coreio.Local.Read(cfgPath) + if err != nil { + t.Fatal(err) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + if cfg["url"] != "https://file.example.com" { + t.Errorf("got url=%q", cfg["url"]) + } + if cfg["token"] != "file-token" { + t.Errorf("got token=%q", cfg["token"]) + } +} + +func TestConfigPath_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got, err := ConfigPath() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".config", "forge", "config.json") + if got != want { + t.Fatalf("got path=%q, want %q", got, want) + } +} diff --git a/contents.go b/contents.go index 8a6f48e..78d5c21 100644 --- a/contents.go +++ b/contents.go @@ -2,13 +2,20 @@ package forge import ( "context" - "fmt" + "iter" + "net/url" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // ContentService handles file read/write operations via the Forgejo API. // No Resource embedding — paths vary by operation. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Contents.GetFile(ctx, "core", "go-forge", "README.md") type ContentService struct { client *Client } @@ -17,9 +24,48 @@ func newContentService(c *Client) *ContentService { return &ContentService{client: c} } +// ListContents returns the entries in a repository directory. +// If ref is non-empty, the listing is resolved against that branch, tag, or commit. +func (s *ContentService) ListContents(ctx context.Context, owner, repo, ref string) ([]types.ContentsResponse, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents", pathParams("owner", owner, "repo", repo)) + if ref != "" { + u, err := url.Parse(path) + if err != nil { + return nil, core.E("ContentService.ListContents", "forge: parse path", err) + } + q := u.Query() + q.Set("ref", ref) + u.RawQuery = q.Encode() + path = u.String() + } + + var out []types.ContentsResponse + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + +// IterContents returns an iterator over the entries in a repository directory. +// If ref is non-empty, the listing is resolved against that branch, tag, or commit. +func (s *ContentService) IterContents(ctx context.Context, owner, repo, ref string) iter.Seq2[types.ContentsResponse, error] { + return func(yield func(types.ContentsResponse, error) bool) { + items, err := s.ListContents(ctx, owner, repo, ref) + if err != nil { + yield(*new(types.ContentsResponse), err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + // GetFile returns metadata and content for a file in a repository. func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) var out types.ContentsResponse if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -29,7 +75,7 @@ func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath stri // CreateFile creates a new file in a repository. func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath string, opts *types.CreateFileOptions) (*types.FileResponse, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) var out types.FileResponse if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -39,7 +85,7 @@ func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath s // UpdateFile updates an existing file in a repository. func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath string, opts *types.UpdateFileOptions) (*types.FileResponse, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) var out types.FileResponse if err := s.client.Put(ctx, path, opts, &out); err != nil { return nil, err @@ -49,12 +95,12 @@ func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath s // DeleteFile deletes a file from a repository. Uses DELETE with a JSON body. func (s *ContentService) DeleteFile(ctx context.Context, owner, repo, filepath string, opts *types.DeleteFileOptions) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) return s.client.DeleteWithBody(ctx, path, opts) } // GetRawFile returns the raw file content as bytes. func (s *ContentService) GetRawFile(ctx context.Context, owner, repo, filepath string) ([]byte, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/raw/%s", owner, repo, filepath) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/raw/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) return s.client.GetRaw(ctx, path) } diff --git a/contents_test.go b/contents_test.go index 1716276..abc0b6d 100644 --- a/contents_test.go +++ b/contents_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,70 @@ import ( "dappco.re/go/core/forge/types" ) -func TestContentService_Good_GetFile(t *testing.T) { +func TestContentService_ListContents_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/contents" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Errorf("got ref=%q, want %q", got, "main") + } + json.NewEncoder(w).Encode([]types.ContentsResponse{ + {Name: "README.md", Path: "README.md", Type: "file"}, + {Name: "docs", Path: "docs", Type: "dir"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + items, err := f.Contents.ListContents(context.Background(), "core", "go-forge", "main") + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("got %d items, want 2", len(items)) + } + if items[0].Name != "README.md" || items[1].Type != "dir" { + t.Fatalf("unexpected results: %+v", items) + } +} + +func TestContentService_IterContents_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/contents" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.ContentsResponse{ + {Name: "README.md", Path: "README.md", Type: "file"}, + {Name: "docs", Path: "docs", Type: "dir"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for item, err := range f.Contents.IterContents(context.Background(), "core", "go-forge", "") { + if err != nil { + t.Fatal(err) + } + got = append(got, item.Name) + } + if len(got) != 2 { + t.Fatalf("got %d items, want 2", len(got)) + } + if got[0] != "README.md" || got[1] != "docs" { + t.Fatalf("unexpected items: %+v", got) + } +} + +func TestContentService_GetFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -49,7 +112,7 @@ func TestContentService_Good_GetFile(t *testing.T) { } } -func TestContentService_Good_CreateFile(t *testing.T) { +func TestContentService_CreateFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -98,7 +161,7 @@ func TestContentService_Good_CreateFile(t *testing.T) { } } -func TestContentService_Good_UpdateFile(t *testing.T) { +func TestContentService_UpdateFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Errorf("expected PUT, got %s", r.Method) @@ -138,7 +201,7 @@ func TestContentService_Good_UpdateFile(t *testing.T) { } } -func TestContentService_Good_DeleteFile(t *testing.T) { +func TestContentService_DeleteFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -168,7 +231,7 @@ func TestContentService_Good_DeleteFile(t *testing.T) { } } -func TestContentService_Good_GetRawFile(t *testing.T) { +func TestContentService_GetRawFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -192,7 +255,7 @@ func TestContentService_Good_GetRawFile(t *testing.T) { } } -func TestContentService_Bad_NotFound(t *testing.T) { +func TestContentService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "file not found"}) @@ -209,7 +272,7 @@ func TestContentService_Bad_NotFound(t *testing.T) { } } -func TestContentService_Bad_GetRawNotFound(t *testing.T) { +func TestContentService_GetRawNotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "file not found"}) diff --git a/doc.go b/doc.go index e0bd38c..1a3193d 100644 --- a/doc.go +++ b/doc.go @@ -2,8 +2,9 @@ // // Usage: // +// ctx := context.Background() // f := forge.NewForge("https://forge.lthn.ai", "your-token") -// repos, err := f.Repos.List(ctx, forge.Params{"org": "core"}, forge.DefaultList) +// repos, err := f.Repos.ListOrgRepos(ctx, "core") // // Types are generated from Forgejo's swagger.v1.json spec via cmd/forgegen/. // Run `go generate ./types/...` to regenerate after a Forgejo upgrade. diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..0f56abc --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,525 @@ +# API Contract Inventory + +`CODEX.md` was not present in `/workspace`, so this inventory follows the repository’s existing Go/doc/test conventions. + +Coverage notes: rows list direct tests when a symbol is named in test names or referenced explicitly in test code. Services that embed `Resource[...]` are documented by their declared methods; promoted CRUD methods are covered under the `Resource` rows instead of being duplicated for every service. + +## `forge` + +| Kind | Name | Signature | Description | Test Coverage | +| --- | --- | --- | --- | --- | +| type | APIError | `type APIError struct` | APIError represents an error response from the Forgejo API. | `TestAPIError_Good_Error`, `TestClient_Bad_ServerError`, `TestIsConflict_Bad_NotConflict` (+2 more) | +| type | ActionsService | `type ActionsService struct` | ActionsService handles CI/CD actions operations across repositories and organisations — secrets, variables, workflow dispatches, and tasks. No Resource embedding — heterogeneous endpoints across repo and org levels. | `TestActionsService_Bad_NotFound`, `TestActionsService_Good_CreateRepoSecret`, `TestActionsService_Good_CreateRepoVariable` (+9 more) | +| type | AdminService | `type AdminService struct` | AdminService handles site administration operations. Unlike other services, AdminService does not embed Resource[T,C,U] because admin endpoints are heterogeneous. | `TestAdminService_Bad_CreateUser_Forbidden`, `TestAdminService_Bad_DeleteUser_NotFound`, `TestAdminService_Good_AdoptRepo` (+9 more) | +| type | BranchService | `type BranchService struct` | BranchService handles branch operations within a repository. | `TestBranchService_Good_CreateProtection`, `TestBranchService_Good_Get`, `TestBranchService_Good_List` | +| type | Client | `type Client struct` | Client is a low-level HTTP client for the Forgejo API. | `TestClient_Bad_Conflict`, `TestClient_Bad_Forbidden`, `TestClient_Bad_NotFound` (+9 more) | +| type | CommitService | `type CommitService struct` | CommitService handles commit-related operations such as commit statuses and git notes. No Resource embedding — collection and item commit paths differ, and the remaining endpoints are heterogeneous across status and note paths. | `TestCommitService_Bad_NotFound`, `TestCommitService_Good_CreateStatus`, `TestCommitService_Good_Get` (+4 more) | +| type | ContentService | `type ContentService struct` | ContentService handles file read/write operations via the Forgejo API. No Resource embedding — paths vary by operation. | `TestContentService_Bad_GetRawNotFound`, `TestContentService_Bad_NotFound`, `TestContentService_Good_CreateFile` (+4 more) | +| type | Forge | `type Forge struct` | Forge is the top-level client for the Forgejo API. | `TestForge_Good_Client`, `TestForge_Good_NewForge` | +| type | IssueService | `type IssueService struct` | IssueService handles issue operations within a repository. | `TestIssueService_Bad_List`, `TestIssueService_Good_Create`, `TestIssueService_Good_CreateComment` (+6 more) | +| type | LabelService | `type LabelService struct` | LabelService handles repository labels, organisation labels, and issue labels. No Resource embedding — paths are heterogeneous. | `TestLabelService_Bad_NotFound`, `TestLabelService_Good_CreateOrgLabel`, `TestLabelService_Good_CreateRepoLabel` (+5 more) | +| type | ListOptions | `type ListOptions struct` | ListOptions controls pagination. | `TestListPage_Good_QueryParams` | +| type | MilestoneService | `type MilestoneService struct` | MilestoneService handles repository milestones. | No direct tests. | +| type | MiscService | `type MiscService struct` | MiscService handles miscellaneous Forgejo API endpoints such as markdown rendering, licence templates, gitignore templates, and server metadata. No Resource embedding — heterogeneous read-only endpoints. | `TestMiscService_Bad_NotFound`, `TestMiscService_Good_GetGitignoreTemplate`, `TestMiscService_Good_GetLicense` (+5 more) | +| type | NotificationService | `type NotificationService struct` | NotificationService handles notification operations via the Forgejo API. No Resource embedding — varied endpoint shapes. | `TestNotificationService_Bad_NotFound`, `TestNotificationService_Good_GetThread`, `TestNotificationService_Good_List` (+3 more) | +| type | Option | `type Option func(*Client)` | Option configures the Client. | No direct tests. | +| type | OrgService | `type OrgService struct` | OrgService handles organisation operations. | `TestOrgService_Good_Get`, `TestOrgService_Good_List`, `TestOrgService_Good_ListMembers` | +| type | PackageService | `type PackageService struct` | PackageService handles package registry operations via the Forgejo API. No Resource embedding — paths vary by operation. | `TestPackageService_Bad_NotFound`, `TestPackageService_Good_Delete`, `TestPackageService_Good_Get` (+2 more) | +| type | PagedResult | `type PagedResult[T any] struct` | PagedResult holds a single page of results with metadata. | No direct tests. | +| type | Params | `type Params map[string]string` | Params maps path variable names to values. Example: Params{"owner": "core", "repo": "go-forge"} | `TestBranchService_Good_Get`, `TestBranchService_Good_List`, `TestCommitService_Good_Get` (+32 more) | +| type | PullService | `type PullService struct` | PullService handles pull request operations within a repository. | `TestPullService_Bad_Merge`, `TestPullService_Good_Create`, `TestPullService_Good_Get` (+2 more) | +| type | RateLimit | `type RateLimit struct` | RateLimit represents the rate limit information from the Forgejo API. | `TestClient_Good_RateLimit` | +| type | ReleaseService | `type ReleaseService struct` | ReleaseService handles release operations within a repository. | `TestReleaseService_Good_Get`, `TestReleaseService_Good_GetByTag`, `TestReleaseService_Good_List` | +| type | RepoService | `type RepoService struct` | RepoService handles repository operations. | `TestRepoService_Bad_Get`, `TestRepoService_Good_Delete`, `TestRepoService_Good_Fork` (+3 more) | +| type | Resource | `type Resource[T any, C any, U any] struct` | Resource provides generic CRUD operations for a Forgejo API resource. T is the resource type, C is the create options type, U is the update options type. | `TestResource_Bad_IterError`, `TestResource_Good_Create`, `TestResource_Good_Delete` (+6 more) | +| type | TeamService | `type TeamService struct` | TeamService handles team operations. | `TestTeamService_Good_AddMember`, `TestTeamService_Good_Get`, `TestTeamService_Good_ListMembers` | +| type | UserService | `type UserService struct` | UserService handles user operations. | `TestUserService_Good_Get`, `TestUserService_Good_GetCurrent`, `TestUserService_Good_ListFollowers` | +| type | WebhookService | `type WebhookService struct` | WebhookService handles webhook (hook) operations for repositories, organisations, and the authenticated user. Embeds Resource for standard CRUD on /api/v1/repos/{owner}/{repo}/hooks/{id}. | `TestWebhookService_Bad_NotFound`, `TestWebhookService_Good_Create`, `TestWebhookService_Good_Get` (+8 more) | +| type | WikiService | `type WikiService struct` | WikiService handles wiki page operations for a repository. No Resource embedding — custom endpoints for wiki CRUD. | `TestWikiService_Bad_NotFound`, `TestWikiService_Good_CreatePage`, `TestWikiService_Good_DeletePage` (+3 more) | +| function | IsConflict | `func IsConflict(err error) bool` | IsConflict returns true if the error is a 409 response. | `TestClient_Bad_Conflict`, `TestIsConflict_Bad_NotConflict`, `TestIsConflict_Good` (+1 more) | +| function | IsForbidden | `func IsForbidden(err error) bool` | IsForbidden returns true if the error is a 403 response. | `TestAdminService_Bad_CreateUser_Forbidden`, `TestClient_Bad_Forbidden`, `TestIsForbidden_Bad_NotForbidden` | +| function | IsNotFound | `func IsNotFound(err error) bool` | IsNotFound returns true if the error is a 404 response. | `TestActionsService_Bad_NotFound`, `TestAdminService_Bad_DeleteUser_NotFound`, `TestClient_Bad_NotFound` (+10 more) | +| function | ListAll | `func ListAll[T any](ctx context.Context, c *Client, path string, query map[string]string) ([]T, error)` | ListAll fetches all pages of results. | `TestOrgService_Good_List`, `TestPagination_Bad_ServerError`, `TestPagination_Good_EmptyResult` (+3 more) | +| function | ListIter | `func ListIter[T any](ctx context.Context, c *Client, path string, query map[string]string) iter.Seq2[T, error]` | ListIter returns an iterator over all resources across all pages. | `TestPagination_Good_Iter` | +| function | ListPage | `func ListPage[T any](ctx context.Context, c *Client, path string, query map[string]string, opts ListOptions) (*PagedResult[T], error)` | ListPage fetches a single page of results. Extra query params can be passed via the query map. | `TestListPage_Good_QueryParams` | +| function | NewClient | `func NewClient(url, token string, opts ...Option) *Client` | NewClient creates a new Forgejo API client. | `TestClient_Bad_Conflict`, `TestClient_Bad_Forbidden`, `TestClient_Bad_NotFound` (+23 more) | +| function | NewForge | `func NewForge(url, token string, opts ...Option) *Forge` | NewForge creates a new Forge client. | `TestActionsService_Bad_NotFound`, `TestActionsService_Good_CreateRepoSecret`, `TestActionsService_Good_CreateRepoVariable` (+109 more) | +| function | NewForgeFromConfig | `func NewForgeFromConfig(flagURL, flagToken string, opts ...Option) (*Forge, error)` | NewForgeFromConfig creates a new Forge client using resolved configuration. It returns an error if no API token is available from flags or environment. | `TestNewForgeFromConfig_Bad_NoToken` | +| function | NewResource | `func NewResource[T any, C any, U any](c *Client, path string) *Resource[T, C, U]` | NewResource creates a new Resource for the given path pattern. The path should be the item path (e.g., /repos/{owner}/{repo}/issues/{index}). The collection path is derived by stripping the last /{placeholder} segment. | `TestResource_Bad_IterError`, `TestResource_Good_Create`, `TestResource_Good_Delete` (+6 more) | +| function | ResolveConfig | `func ResolveConfig(flagURL, flagToken string) (url, token string, err error)` | ResolveConfig resolves the Forgejo URL and API token from flags, environment variables, and built-in defaults. Priority order: flags > env > defaults. Environment variables: - FORGE_URL — base URL of the Forgejo instance - FORGE_TOKEN — API token for authentication | `TestResolveConfig_Good_DefaultURL`, `TestResolveConfig_Good_EnvOverrides`, `TestResolveConfig_Good_FlagOverridesEnv` | +| function | ResolvePath | `func ResolvePath(path string, params Params) string` | ResolvePath substitutes {placeholders} in path with values from params. | `TestResolvePath_Good_NoParams`, `TestResolvePath_Good_Simple`, `TestResolvePath_Good_URLEncoding` (+1 more) | +| function | WithHTTPClient | `func WithHTTPClient(hc *http.Client) Option` | WithHTTPClient sets a custom http.Client. | `TestClient_Good_WithHTTPClient` | +| function | WithUserAgent | `func WithUserAgent(ua string) Option` | WithUserAgent sets the User-Agent header. | `TestClient_Good_Options` | +| method | APIError.Error | `func (e *APIError) Error() string` | No doc comment. | `TestAPIError_Good_Error` | +| method | ActionsService.CreateRepoSecret | `func (s *ActionsService) CreateRepoSecret(ctx context.Context, owner, repo, name string, data string) error` | CreateRepoSecret creates or updates a secret in a repository. Forgejo expects a PUT with {"data": "secret-value"} body. | `TestActionsService_Good_CreateRepoSecret` | +| method | ActionsService.CreateRepoVariable | `func (s *ActionsService) CreateRepoVariable(ctx context.Context, owner, repo, name, value string) error` | CreateRepoVariable creates a new action variable in a repository. Forgejo expects a POST with {"value": "var-value"} body. | `TestActionsService_Good_CreateRepoVariable` | +| method | ActionsService.DeleteRepoSecret | `func (s *ActionsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error` | DeleteRepoSecret removes a secret from a repository. | `TestActionsService_Good_DeleteRepoSecret` | +| method | ActionsService.DeleteRepoVariable | `func (s *ActionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error` | DeleteRepoVariable removes an action variable from a repository. | `TestActionsService_Good_DeleteRepoVariable` | +| method | ActionsService.DispatchWorkflow | `func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error` | DispatchWorkflow triggers a workflow run. | `TestActionsService_Good_DispatchWorkflow` | +| method | ActionsService.IterRepoTasks | `func (s *ActionsService) IterRepoTasks(ctx context.Context, owner, repo string) iter.Seq2[types.ActionTask, error]` | IterRepoTasks returns an iterator over all action tasks for a repository. | `TestActionsService_Good_IterRepoTasks` | +| method | ActionsService.IterOrgSecrets | `func (s *ActionsService) IterOrgSecrets(ctx context.Context, org string) iter.Seq2[types.Secret, error]` | IterOrgSecrets returns an iterator over all secrets for an organisation. | No direct tests. | +| method | ActionsService.IterOrgVariables | `func (s *ActionsService) IterOrgVariables(ctx context.Context, org string) iter.Seq2[types.ActionVariable, error]` | IterOrgVariables returns an iterator over all action variables for an organisation. | No direct tests. | +| method | ActionsService.IterRepoSecrets | `func (s *ActionsService) IterRepoSecrets(ctx context.Context, owner, repo string) iter.Seq2[types.Secret, error]` | IterRepoSecrets returns an iterator over all secrets for a repository. | No direct tests. | +| method | ActionsService.IterRepoVariables | `func (s *ActionsService) IterRepoVariables(ctx context.Context, owner, repo string) iter.Seq2[types.ActionVariable, error]` | IterRepoVariables returns an iterator over all action variables for a repository. | No direct tests. | +| method | ActionsService.ListOrgSecrets | `func (s *ActionsService) ListOrgSecrets(ctx context.Context, org string) ([]types.Secret, error)` | ListOrgSecrets returns all secrets for an organisation. | `TestActionsService_Good_ListOrgSecrets` | +| method | ActionsService.ListOrgVariables | `func (s *ActionsService) ListOrgVariables(ctx context.Context, org string) ([]types.ActionVariable, error)` | ListOrgVariables returns all action variables for an organisation. | `TestActionsService_Good_ListOrgVariables` | +| method | ActionsService.ListRepoSecrets | `func (s *ActionsService) ListRepoSecrets(ctx context.Context, owner, repo string) ([]types.Secret, error)` | ListRepoSecrets returns all secrets for a repository. | `TestActionsService_Bad_NotFound`, `TestActionsService_Good_ListRepoSecrets` | +| method | ActionsService.ListRepoTasks | `func (s *ActionsService) ListRepoTasks(ctx context.Context, owner, repo string, opts ListOptions) (*types.ActionTaskResponse, error)` | ListRepoTasks returns a single page of action tasks for a repository. | `TestActionsService_Good_ListRepoTasks` | +| method | ActionsService.ListRepoVariables | `func (s *ActionsService) ListRepoVariables(ctx context.Context, owner, repo string) ([]types.ActionVariable, error)` | ListRepoVariables returns all action variables for a repository. | `TestActionsService_Good_ListRepoVariables` | +| method | AdminService.AdoptRepo | `func (s *AdminService) AdoptRepo(ctx context.Context, owner, repo string) error` | AdoptRepo adopts an unadopted repository (admin only). | `TestAdminService_Good_AdoptRepo` | +| method | AdminService.CreateUser | `func (s *AdminService) CreateUser(ctx context.Context, opts *types.CreateUserOption) (*types.User, error)` | CreateUser creates a new user (admin only). | `TestAdminService_Bad_CreateUser_Forbidden`, `TestAdminService_Good_CreateUser` | +| method | AdminService.DeleteUser | `func (s *AdminService) DeleteUser(ctx context.Context, username string) error` | DeleteUser deletes a user (admin only). | `TestAdminService_Bad_DeleteUser_NotFound`, `TestAdminService_Good_DeleteUser` | +| method | AdminService.EditUser | `func (s *AdminService) EditUser(ctx context.Context, username string, opts map[string]any) error` | EditUser edits an existing user (admin only). | `TestAdminService_Good_EditUser` | +| method | AdminService.GenerateRunnerToken | `func (s *AdminService) GenerateRunnerToken(ctx context.Context) (string, error)` | GenerateRunnerToken generates an actions runner registration token. | `TestAdminService_Good_GenerateRunnerToken` | +| method | AdminService.IterCron | `func (s *AdminService) IterCron(ctx context.Context) iter.Seq2[types.Cron, error]` | IterCron returns an iterator over all cron tasks (admin only). | No direct tests. | +| method | AdminService.IterEmails | `func (s *AdminService) IterEmails(ctx context.Context) iter.Seq2[types.Email, error]` | IterEmails returns an iterator over all email addresses (admin only). | No direct tests. | +| method | AdminService.IterOrgs | `func (s *AdminService) IterOrgs(ctx context.Context) iter.Seq2[types.Organization, error]` | IterOrgs returns an iterator over all organisations (admin only). | No direct tests. | +| method | AdminService.IterSearchEmails | `func (s *AdminService) IterSearchEmails(ctx context.Context, q string) iter.Seq2[types.Email, error]` | IterSearchEmails returns an iterator over all email addresses matching a keyword (admin only). | No direct tests. | +| method | AdminService.IterUsers | `func (s *AdminService) IterUsers(ctx context.Context) iter.Seq2[types.User, error]` | IterUsers returns an iterator over all users (admin only). | No direct tests. | +| method | AdminService.ListCron | `func (s *AdminService) ListCron(ctx context.Context) ([]types.Cron, error)` | ListCron returns all cron tasks (admin only). | `TestAdminService_Good_ListCron` | +| method | AdminService.ListEmails | `func (s *AdminService) ListEmails(ctx context.Context) ([]types.Email, error)` | ListEmails returns all email addresses (admin only). | `TestAdminService_ListEmails_Good` | +| method | AdminService.ListOrgs | `func (s *AdminService) ListOrgs(ctx context.Context) ([]types.Organization, error)` | ListOrgs returns all organisations (admin only). | `TestAdminService_Good_ListOrgs` | +| method | AdminService.ListUsers | `func (s *AdminService) ListUsers(ctx context.Context) ([]types.User, error)` | ListUsers returns all users (admin only). | `TestAdminService_Good_ListUsers` | +| method | AdminService.SearchEmails | `func (s *AdminService) SearchEmails(ctx context.Context, q string) ([]types.Email, error)` | SearchEmails searches all email addresses by keyword (admin only). | `TestAdminService_SearchEmails_Good` | +| method | AdminService.RenameUser | `func (s *AdminService) RenameUser(ctx context.Context, username, newName string) error` | RenameUser renames a user (admin only). | `TestAdminService_Good_RenameUser` | +| method | AdminService.RunCron | `func (s *AdminService) RunCron(ctx context.Context, task string) error` | RunCron runs a cron task by name (admin only). | `TestAdminService_Good_RunCron` | +| method | BranchService.CreateBranchProtection | `func (s *BranchService) CreateBranchProtection(ctx context.Context, owner, repo string, opts *types.CreateBranchProtectionOption) (*types.BranchProtection, error)` | CreateBranchProtection creates a new branch protection rule. | `TestBranchService_Good_CreateProtection` | +| method | BranchService.DeleteBranchProtection | `func (s *BranchService) DeleteBranchProtection(ctx context.Context, owner, repo, name string) error` | DeleteBranchProtection deletes a branch protection rule. | No direct tests. | +| method | BranchService.EditBranchProtection | `func (s *BranchService) EditBranchProtection(ctx context.Context, owner, repo, name string, opts *types.EditBranchProtectionOption) (*types.BranchProtection, error)` | EditBranchProtection updates an existing branch protection rule. | No direct tests. | +| method | BranchService.GetBranchProtection | `func (s *BranchService) GetBranchProtection(ctx context.Context, owner, repo, name string) (*types.BranchProtection, error)` | GetBranchProtection returns a single branch protection by name. | No direct tests. | +| method | BranchService.IterBranchProtections | `func (s *BranchService) IterBranchProtections(ctx context.Context, owner, repo string) iter.Seq2[types.BranchProtection, error]` | IterBranchProtections returns an iterator over all branch protections for a repository. | No direct tests. | +| method | BranchService.ListBranchProtections | `func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error)` | ListBranchProtections returns all branch protections for a repository. | No direct tests. | +| method | Client.Delete | `func (c *Client) Delete(ctx context.Context, path string) error` | Delete performs a DELETE request. | `TestClient_Good_Delete` | +| method | Client.DeleteWithBody | `func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error` | DeleteWithBody performs a DELETE request with a JSON body. | No direct tests. | +| method | Client.Get | `func (c *Client) Get(ctx context.Context, path string, out any) error` | Get performs a GET request. | `TestClient_Bad_Forbidden`, `TestClient_Bad_NotFound`, `TestClient_Bad_ServerError` (+3 more) | +| method | Client.GetRaw | `func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error)` | GetRaw performs a GET request and returns the raw response body as bytes instead of JSON-decoding. Useful for endpoints that return raw file content. | No direct tests. | +| method | Client.HasToken | `func (c *Client) HasToken() bool` | HasToken reports whether the client was configured with an API token. | `TestClient_HasToken_Bad`, `TestClient_HasToken_Good` | +| method | Client.HTTPClient | `func (c *Client) HTTPClient() *http.Client` | HTTPClient returns the configured underlying HTTP client. | `TestClient_Good_WithHTTPClient` | +| method | Client.Patch | `func (c *Client) Patch(ctx context.Context, path string, body, out any) error` | Patch performs a PATCH request. | No direct tests. | +| method | Client.Post | `func (c *Client) Post(ctx context.Context, path string, body, out any) error` | Post performs a POST request. | `TestClient_Bad_Conflict`, `TestClient_Good_Post` | +| method | Client.PostRaw | `func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error)` | PostRaw performs a POST request with a JSON body and returns the raw response body as bytes instead of JSON-decoding. Useful for endpoints such as /markdown that return raw HTML text. | No direct tests. | +| method | Client.Put | `func (c *Client) Put(ctx context.Context, path string, body, out any) error` | Put performs a PUT request. | No direct tests. | +| method | Client.RateLimit | `func (c *Client) RateLimit() RateLimit` | RateLimit returns the last known rate limit information. | `TestClient_Good_RateLimit` | +| method | Client.UserAgent | `func (c *Client) UserAgent() string` | UserAgent returns the configured User-Agent header value. | `TestClient_Good_Options` | +| method | CommitService.CreateStatus | `func (s *CommitService) CreateStatus(ctx context.Context, owner, repo, sha string, opts *types.CreateStatusOption) (*types.CommitStatus, error)` | CreateStatus creates a new commit status for the given SHA. | `TestCommitService_Good_CreateStatus` | +| method | CommitService.Get | `func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, error)` | Get returns a single commit by SHA or ref. | `TestCommitService_Good_Get`, `TestCommitService_Good_List` | +| method | CommitService.GetCombinedStatus | `func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*types.CombinedStatus, error)` | GetCombinedStatus returns the combined status for a given ref (branch, tag, or SHA). | `TestCommitService_Good_GetCombinedStatus` | +| method | CommitService.GetNote | `func (s *CommitService) GetNote(ctx context.Context, owner, repo, sha string) (*types.Note, error)` | GetNote returns the git note for a given commit SHA. | `TestCommitService_Bad_NotFound`, `TestCommitService_Good_GetNote` | +| method | CommitService.Iter | `func (s *CommitService) Iter(ctx context.Context, params Params) iter.Seq2[types.Commit, error]` | Iter returns an iterator over all commits for a repository. | No direct tests. | +| method | CommitService.List | `func (s *CommitService) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[types.Commit], error)` | List returns a single page of commits for a repository. | `TestCommitService_Good_List` | +| method | CommitService.ListAll | `func (s *CommitService) ListAll(ctx context.Context, params Params) ([]types.Commit, error)` | ListAll returns all commits for a repository. | No direct tests. | +| method | CommitService.ListStatuses | `func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref string) ([]types.CommitStatus, error)` | ListStatuses returns all commit statuses for a given ref. | `TestCommitService_Good_ListStatuses` | +| method | ContentService.CreateFile | `func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath string, opts *types.CreateFileOptions) (*types.FileResponse, error)` | CreateFile creates a new file in a repository. | `TestContentService_Good_CreateFile` | +| method | ContentService.DeleteFile | `func (s *ContentService) DeleteFile(ctx context.Context, owner, repo, filepath string, opts *types.DeleteFileOptions) error` | DeleteFile deletes a file from a repository. Uses DELETE with a JSON body. | `TestContentService_Good_DeleteFile` | +| method | ContentService.GetFile | `func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error)` | GetFile returns metadata and content for a file in a repository. | `TestContentService_Bad_NotFound`, `TestContentService_Good_GetFile` | +| method | ContentService.GetRawFile | `func (s *ContentService) GetRawFile(ctx context.Context, owner, repo, filepath string) ([]byte, error)` | GetRawFile returns the raw file content as bytes. | `TestContentService_Bad_GetRawNotFound`, `TestContentService_Good_GetRawFile` | +| method | ContentService.UpdateFile | `func (s *ContentService) UpdateFile(ctx context.Context, owner, repo, filepath string, opts *types.UpdateFileOptions) (*types.FileResponse, error)` | UpdateFile updates an existing file in a repository. | `TestContentService_Good_UpdateFile` | +| method | Forge.Client | `func (f *Forge) Client() *Client` | Client returns the underlying HTTP client. | `TestForge_Good_Client` | +| method | Forge.HasToken | `func (f *Forge) HasToken() bool` | HasToken reports whether the Forge client was configured with an API token. | `TestForge_HasToken_Bad`, `TestForge_HasToken_Good` | +| method | Forge.HTTPClient | `func (f *Forge) HTTPClient() *http.Client` | HTTPClient returns the configured underlying HTTP client. | `TestForge_HTTPClient_Good` | +| method | Forge.RateLimit | `func (f *Forge) RateLimit() RateLimit` | RateLimit returns the last known rate limit information. | `TestForge_Good_RateLimit` | +| method | Forge.UserAgent | `func (f *Forge) UserAgent() string` | UserAgent returns the configured User-Agent header value. | `TestForge_Good_UserAgent` | +| method | IssueService.AddLabels | `func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error` | AddLabels adds labels to an issue. | No direct tests. | +| method | IssueService.AddReaction | `func (s *IssueService) AddReaction(ctx context.Context, owner, repo string, index int64, reaction string) error` | AddReaction adds a reaction to an issue. | No direct tests. | +| method | IssueService.CreateComment | `func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, index int64, body string) (*types.Comment, error)` | CreateComment creates a comment on an issue. | `TestIssueService_Good_CreateComment` | +| method | IssueService.AddTime | `func (s *IssueService) AddTime(ctx context.Context, owner, repo string, index int64, opts *types.AddTimeOption) (*types.TrackedTime, error)` | AddTime adds tracked time to an issue. | `TestIssueService_AddTime_Good` | +| method | IssueService.DeleteStopwatch | `func (s *IssueService) DeleteStopwatch(ctx context.Context, owner, repo string, index int64) error` | DeleteStopwatch deletes an issue's existing stopwatch. | `TestIssueService_DeleteStopwatch_Good` | +| method | IssueService.DeleteReaction | `func (s *IssueService) DeleteReaction(ctx context.Context, owner, repo string, index int64, reaction string) error` | DeleteReaction removes a reaction from an issue. | No direct tests. | +| method | IssueService.IterComments | `func (s *IssueService) IterComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error]` | IterComments returns an iterator over all comments on an issue. | No direct tests. | +| method | IssueService.ListComments | `func (s *IssueService) ListComments(ctx context.Context, owner, repo string, index int64) ([]types.Comment, error)` | ListComments returns all comments on an issue. | No direct tests. | +| method | IssueService.IterTimeline | `func (s *IssueService) IterTimeline(ctx context.Context, owner, repo string, index int64, since, before *time.Time) iter.Seq2[types.TimelineComment, error]` | IterTimeline returns an iterator over all comments and events on an issue. | `TestIssueService_IterTimeline_Good` | +| method | IssueService.ListTimeline | `func (s *IssueService) ListTimeline(ctx context.Context, owner, repo string, index int64, since, before *time.Time) ([]types.TimelineComment, error)` | ListTimeline returns all comments and events on an issue. | `TestIssueService_ListTimeline_Good` | +| method | IssueService.ListTimes | `func (s *IssueService) ListTimes(ctx context.Context, owner, repo string, index int64, user string, since, before *time.Time) ([]types.TrackedTime, error)` | ListTimes returns all tracked times on an issue. | `TestIssueService_ListTimes_Good` | +| method | IssueService.IterTimes | `func (s *IssueService) IterTimes(ctx context.Context, owner, repo string, index int64, user string, since, before *time.Time) iter.Seq2[types.TrackedTime, error]` | IterTimes returns an iterator over all tracked times on an issue. | `TestIssueService_IterTimes_Good` | +| method | IssueService.Pin | `func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error` | Pin pins an issue. | `TestIssueService_Good_Pin` | +| method | IssueService.RemoveLabel | `func (s *IssueService) RemoveLabel(ctx context.Context, owner, repo string, index int64, labelID int64) error` | RemoveLabel removes a single label from an issue. | No direct tests. | +| method | IssueService.SetDeadline | `func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline string) error` | SetDeadline sets or updates the deadline on an issue. | No direct tests. | +| method | IssueService.StartStopwatch | `func (s *IssueService) StartStopwatch(ctx context.Context, owner, repo string, index int64) error` | StartStopwatch starts the stopwatch on an issue. | No direct tests. | +| method | IssueService.StopStopwatch | `func (s *IssueService) StopStopwatch(ctx context.Context, owner, repo string, index int64) error` | StopStopwatch stops the stopwatch on an issue. | No direct tests. | +| method | IssueService.ResetTime | `func (s *IssueService) ResetTime(ctx context.Context, owner, repo string, index int64) error` | ResetTime removes all tracked time from an issue. | `TestIssueService_ResetTime_Good` | +| method | IssueService.Unpin | `func (s *IssueService) Unpin(ctx context.Context, owner, repo string, index int64) error` | Unpin unpins an issue. | No direct tests. | +| method | LabelService.CreateOrgLabel | `func (s *LabelService) CreateOrgLabel(ctx context.Context, org string, opts *types.CreateLabelOption) (*types.Label, error)` | CreateOrgLabel creates a new label in an organisation. | `TestLabelService_Good_CreateOrgLabel` | +| method | LabelService.CreateRepoLabel | `func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, opts *types.CreateLabelOption) (*types.Label, error)` | CreateRepoLabel creates a new label in a repository. | `TestLabelService_Good_CreateRepoLabel` | +| method | LabelService.DeleteRepoLabel | `func (s *LabelService) DeleteRepoLabel(ctx context.Context, owner, repo string, id int64) error` | DeleteRepoLabel deletes a label from a repository. | `TestLabelService_Good_DeleteRepoLabel` | +| method | LabelService.EditRepoLabel | `func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id int64, opts *types.EditLabelOption) (*types.Label, error)` | EditRepoLabel updates an existing label in a repository. | `TestLabelService_Good_EditRepoLabel` | +| method | LabelService.GetRepoLabel | `func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error)` | GetRepoLabel returns a single label by ID. | `TestLabelService_Bad_NotFound`, `TestLabelService_Good_GetRepoLabel` | +| method | LabelService.IterOrgLabels | `func (s *LabelService) IterOrgLabels(ctx context.Context, org string) iter.Seq2[types.Label, error]` | IterOrgLabels returns an iterator over all labels for an organisation. | No direct tests. | +| method | LabelService.IterRepoLabels | `func (s *LabelService) IterRepoLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error]` | IterRepoLabels returns an iterator over all labels for a repository. | No direct tests. | +| method | LabelService.ListOrgLabels | `func (s *LabelService) ListOrgLabels(ctx context.Context, org string) ([]types.Label, error)` | ListOrgLabels returns all labels for an organisation. | `TestLabelService_Good_ListOrgLabels` | +| method | LabelService.ListRepoLabels | `func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error)` | ListRepoLabels returns all labels for a repository. | `TestLabelService_Good_ListRepoLabels` | +| method | MilestoneService.Create | `func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error)` | Create creates a new milestone. | No direct tests. | +| method | MilestoneService.Get | `func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error)` | Get returns a single milestone by ID. | No direct tests. | +| method | MilestoneService.ListAll | `func (s *MilestoneService) ListAll(ctx context.Context, params Params, filters ...MilestoneListOptions) ([]types.Milestone, error)` | ListAll returns all milestones for a repository. | No direct tests. | +| method | MiscService.GetGitignoreTemplate | `func (s *MiscService) GetGitignoreTemplate(ctx context.Context, name string) (*types.GitignoreTemplateInfo, error)` | GetGitignoreTemplate returns a single gitignore template by name. | `TestMiscService_Good_GetGitignoreTemplate` | +| method | MiscService.GetAPISettings | `func (s *MiscService) GetAPISettings(ctx context.Context) (*types.GeneralAPISettings, error)` | GetAPISettings returns the instance's global API settings. | `TestMiscService_GetAPISettings_Good` | +| method | MiscService.GetAttachmentSettings | `func (s *MiscService) GetAttachmentSettings(ctx context.Context) (*types.GeneralAttachmentSettings, error)` | GetAttachmentSettings returns the instance's global attachment settings. | `TestMiscService_GetAttachmentSettings_Good` | +| method | MiscService.GetLicense | `func (s *MiscService) GetLicense(ctx context.Context, name string) (*types.LicenseTemplateInfo, error)` | GetLicense returns a single licence template by name. | `TestMiscService_Bad_NotFound`, `TestMiscService_Good_GetLicense` | +| method | MiscService.GetNodeInfo | `func (s *MiscService) GetNodeInfo(ctx context.Context) (*types.NodeInfo, error)` | GetNodeInfo returns the NodeInfo metadata for the Forgejo instance. | `TestMiscService_Good_GetNodeInfo` | +| method | MiscService.GetRepositorySettings | `func (s *MiscService) GetRepositorySettings(ctx context.Context) (*types.GeneralRepoSettings, error)` | GetRepositorySettings returns the instance's global repository settings. | `TestMiscService_GetRepositorySettings_Good` | +| method | MiscService.GetVersion | `func (s *MiscService) GetVersion(ctx context.Context) (*types.ServerVersion, error)` | GetVersion returns the server version. | `TestMiscService_Good_GetVersion` | +| method | MiscService.GetUISettings | `func (s *MiscService) GetUISettings(ctx context.Context) (*types.GeneralUISettings, error)` | GetUISettings returns the instance's global UI settings. | `TestMiscService_GetUISettings_Good` | +| method | MiscService.ListGitignoreTemplates | `func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, error)` | ListGitignoreTemplates returns all available gitignore template names. | `TestMiscService_Good_ListGitignoreTemplates` | +| method | MiscService.ListLicenses | `func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplateListEntry, error)` | ListLicenses returns all available licence templates. | `TestMiscService_Good_ListLicenses` | +| method | MiscService.RenderMarkdown | `func (s *MiscService) RenderMarkdown(ctx context.Context, text, mode string) (string, error)` | RenderMarkdown renders markdown text to HTML. The response is raw HTML text, not JSON. | `TestMiscService_Good_RenderMarkdown` | +| method | MiscService.RenderMarkup | `func (s *MiscService) RenderMarkup(ctx context.Context, text, mode string) (string, error)` | RenderMarkup renders markup text to HTML. The response is raw HTML text, not JSON. | `TestMiscService_Good_RenderMarkup` | +| method | MiscService.RenderMarkdownRaw | `func (s *MiscService) RenderMarkdownRaw(ctx context.Context, text string) (string, error)` | RenderMarkdownRaw renders raw markdown text to HTML. The request body is sent as text/plain and the response is raw HTML text, not JSON. | `TestMiscService_RenderMarkdownRaw_Good` | +| method | NotificationService.GetThread | `func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.NotificationThread, error)` | GetThread returns a single notification thread by ID. | `TestNotificationService_Bad_NotFound`, `TestNotificationService_Good_GetThread` | +| method | NotificationService.Iter | `func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.NotificationThread, error]` | Iter returns an iterator over all notifications for the authenticated user. | No direct tests. | +| method | NotificationService.IterRepo | `func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string) iter.Seq2[types.NotificationThread, error]` | IterRepo returns an iterator over all notifications for a specific repository. | No direct tests. | +| method | NotificationService.List | `func (s *NotificationService) List(ctx context.Context) ([]types.NotificationThread, error)` | List returns all notifications for the authenticated user. | `TestNotificationService_Good_List` | +| method | NotificationService.ListRepo | `func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error)` | ListRepo returns all notifications for a specific repository. | `TestNotificationService_Good_ListRepo` | +| method | NotificationService.MarkNotifications | `func (s *NotificationService) MarkNotifications(ctx context.Context, opts *NotificationMarkOptions) ([]types.NotificationThread, error)` | MarkNotifications marks authenticated-user notification threads as read, pinned, or unread. | `TestNotificationService_MarkNotifications_Good` | +| method | NotificationService.MarkRepoNotifications | `func (s *NotificationService) MarkRepoNotifications(ctx context.Context, owner, repo string, opts *NotificationRepoMarkOptions) ([]types.NotificationThread, error)` | MarkRepoNotifications marks repository notification threads as read, unread, or pinned. | `TestNotificationService_MarkRepoNotifications_Good` | +| method | NotificationService.MarkRead | `func (s *NotificationService) MarkRead(ctx context.Context) error` | MarkRead marks all notifications as read. | `TestNotificationService_Good_MarkRead` | +| method | NotificationService.MarkThreadRead | `func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) error` | MarkThreadRead marks a single notification thread as read. | `TestNotificationService_Good_MarkThreadRead` | +| method | OrgService.AddMember | `func (s *OrgService) AddMember(ctx context.Context, org, username string) error` | AddMember adds a user to an organisation. | No direct tests. | +| method | OrgService.IterMembers | `func (s *OrgService) IterMembers(ctx context.Context, org string) iter.Seq2[types.User, error]` | IterMembers returns an iterator over all members of an organisation. | No direct tests. | +| method | OrgService.IterMyOrgs | `func (s *OrgService) IterMyOrgs(ctx context.Context) iter.Seq2[types.Organization, error]` | IterMyOrgs returns an iterator over all organisations for the authenticated user. | No direct tests. | +| method | OrgService.IterUserOrgs | `func (s *OrgService) IterUserOrgs(ctx context.Context, username string) iter.Seq2[types.Organization, error]` | IterUserOrgs returns an iterator over all organisations for a user. | No direct tests. | +| method | OrgService.ListMembers | `func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error)` | ListMembers returns all members of an organisation. | `TestOrgService_Good_ListMembers` | +| method | OrgService.ListMyOrgs | `func (s *OrgService) ListMyOrgs(ctx context.Context) ([]types.Organization, error)` | ListMyOrgs returns all organisations for the authenticated user. | No direct tests. | +| method | OrgService.ListUserOrgs | `func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error)` | ListUserOrgs returns all organisations for a user. | No direct tests. | +| method | OrgService.RemoveMember | `func (s *OrgService) RemoveMember(ctx context.Context, org, username string) error` | RemoveMember removes a user from an organisation. | No direct tests. | +| method | PackageService.Delete | `func (s *PackageService) Delete(ctx context.Context, owner, pkgType, name, version string) error` | Delete removes a package by owner, type, name, and version. | `TestPackageService_Good_Delete` | +| method | PackageService.Get | `func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version string) (*types.Package, error)` | Get returns a single package by owner, type, name, and version. | `TestPackageService_Bad_NotFound`, `TestPackageService_Good_Get` | +| method | PackageService.Iter | `func (s *PackageService) Iter(ctx context.Context, owner string) iter.Seq2[types.Package, error]` | Iter returns an iterator over all packages for a given owner. | No direct tests. | +| method | PackageService.IterFiles | `func (s *PackageService) IterFiles(ctx context.Context, owner, pkgType, name, version string) iter.Seq2[types.PackageFile, error]` | IterFiles returns an iterator over all files for a specific package version. | No direct tests. | +| method | PackageService.List | `func (s *PackageService) List(ctx context.Context, owner string) ([]types.Package, error)` | List returns all packages for a given owner. | `TestPackageService_Good_List` | +| method | PackageService.ListFiles | `func (s *PackageService) ListFiles(ctx context.Context, owner, pkgType, name, version string) ([]types.PackageFile, error)` | ListFiles returns all files for a specific package version. | `TestPackageService_Good_ListFiles` | +| method | PullService.DismissReview | `func (s *PullService) DismissReview(ctx context.Context, owner, repo string, index, reviewID int64, msg string) error` | DismissReview dismisses a pull request review. | No direct tests. | +| method | PullService.CancelReviewRequests | `func (s *PullService) CancelReviewRequests(ctx context.Context, owner, repo string, index int64, opts *types.PullReviewRequestOptions) error` | CancelReviewRequests cancels review requests for a pull request. | `TestPullService_CancelReviewRequests_Good` | +| method | PullService.IterReviews | `func (s *PullService) IterReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error]` | IterReviews returns an iterator over all reviews on a pull request. | No direct tests. | +| method | PullService.ListReviews | `func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error)` | ListReviews returns all reviews on a pull request. | No direct tests. | +| method | PullService.Merge | `func (s *PullService) Merge(ctx context.Context, owner, repo string, index int64, method string) error` | Merge merges a pull request. Method is one of "merge", "rebase", "rebase-merge", "squash", "fast-forward-only", "manually-merged". | `TestPullService_Bad_Merge`, `TestPullService_Good_Merge` | +| method | PullService.RequestReviewers | `func (s *PullService) RequestReviewers(ctx context.Context, owner, repo string, index int64, opts *types.PullReviewRequestOptions) ([]types.PullReview, error)` | RequestReviewers creates review requests for a pull request. | `TestPullService_RequestReviewers_Good` | +| method | PullService.SubmitReview | `func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, index int64, review map[string]any) (*types.PullReview, error)` | SubmitReview creates a new review on a pull request. | No direct tests. | +| method | PullService.UndismissReview | `func (s *PullService) UndismissReview(ctx context.Context, owner, repo string, index, reviewID int64) error` | UndismissReview undismisses a pull request review. | No direct tests. | +| method | PullService.Update | `func (s *PullService) Update(ctx context.Context, owner, repo string, index int64) error` | Update updates a pull request branch with the base branch. | No direct tests. | +| method | ReleaseService.DeleteAsset | `func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) error` | DeleteAsset deletes a single asset from a release. | No direct tests. | +| method | ReleaseService.EditAsset | `func (s *ReleaseService) EditAsset(ctx context.Context, owner, repo string, releaseID, attachmentID int64, opts *types.EditAttachmentOptions) (*types.Attachment, error)` | EditAsset updates a release asset. | `TestReleaseService_EditAttachment_Good` | +| method | ReleaseService.DeleteByTag | `func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag string) error` | DeleteByTag deletes a release by its tag name. | No direct tests. | +| method | ReleaseService.GetAsset | `func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) (*types.Attachment, error)` | GetAsset returns a single asset for a release. | No direct tests. | +| method | ReleaseService.GetByTag | `func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) (*types.Release, error)` | GetByTag returns a release by its tag name. | `TestReleaseService_Good_GetByTag` | +| method | ReleaseService.GetLatest | `func (s *ReleaseService) GetLatest(ctx context.Context, owner, repo string) (*types.Release, error)` | GetLatest returns the most recent non-prerelease, non-draft release. | `TestReleaseService_GetLatest_Good` | +| method | ReleaseService.IterAssets | `func (s *ReleaseService) IterAssets(ctx context.Context, owner, repo string, releaseID int64) iter.Seq2[types.Attachment, error]` | IterAssets returns an iterator over all assets for a release. | No direct tests. | +| method | ReleaseService.ListAssets | `func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, releaseID int64) ([]types.Attachment, error)` | ListAssets returns all assets for a release. | No direct tests. | +| method | RepoService.AcceptTransfer | `func (s *RepoService) AcceptTransfer(ctx context.Context, owner, repo string) error` | AcceptTransfer accepts a pending repository transfer. | No direct tests. | +| method | RepoService.Fork | `func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error)` | Fork forks a repository. If org is non-empty, forks into that organisation. | `TestRepoService_Good_Fork` | +| method | RepoService.GetArchive | `func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive string) ([]byte, error)` | GetArchive returns a repository archive as raw bytes. | `TestRepoService_GetArchive_Good` | +| method | RepoService.GetSigningKey | `func (s *RepoService) GetSigningKey(ctx context.Context, owner, repo string) (string, error)` | GetSigningKey returns the repository signing key as ASCII-armoured text. | `TestRepoService_GetSigningKey_Good` | +| method | RepoService.GetNewPinAllowed | `func (s *RepoService) GetNewPinAllowed(ctx context.Context, owner, repo string) (*types.NewIssuePinsAllowed, error)` | GetNewPinAllowed returns whether new issue pins are allowed for a repository. | `TestRepoService_GetNewPinAllowed_Good` | +| method | RepoService.IterOrgRepos | `func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[types.Repository, error]` | IterOrgRepos returns an iterator over all repositories for an organisation. | No direct tests. | +| method | RepoService.IterUserRepos | `func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Repository, error]` | IterUserRepos returns an iterator over all repositories for the authenticated user. | No direct tests. | +| method | RepoService.ListOrgRepos | `func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error)` | ListOrgRepos returns all repositories for an organisation. | `TestRepoService_Good_ListOrgRepos` | +| method | RepoService.ListUserRepos | `func (s *RepoService) ListUserRepos(ctx context.Context) ([]types.Repository, error)` | ListUserRepos returns all repositories for the authenticated user. | No direct tests. | +| method | RepoService.MirrorSync | `func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error` | MirrorSync triggers a mirror sync. | No direct tests. | +| method | RepoService.RejectTransfer | `func (s *RepoService) RejectTransfer(ctx context.Context, owner, repo string) error` | RejectTransfer rejects a pending repository transfer. | No direct tests. | +| method | RepoService.DeleteAvatar | `func (s *RepoService) DeleteAvatar(ctx context.Context, owner, repo string) error` | DeleteAvatar deletes a repository avatar. | `TestRepoService_DeleteAvatar_Good` | +| method | RepoService.UpdateAvatar | `func (s *RepoService) UpdateAvatar(ctx context.Context, owner, repo string, opts *types.UpdateRepoAvatarOption) error` | UpdateAvatar updates a repository avatar. | `TestRepoService_UpdateAvatar_Good` | +| method | RepoService.Transfer | `func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts map[string]any) error` | Transfer initiates a repository transfer. | No direct tests. | +| method | Resource.Create | `func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error)` | Create creates a new resource. | `TestResource_Good_Create` | +| method | Resource.Delete | `func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params) error` | Delete removes a resource. | `TestResource_Good_Delete` | +| method | Resource.Get | `func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error)` | Get returns a single resource by appending id to the path. | `TestResource_Good_Get` | +| method | Resource.Iter | `func (r *Resource[T, C, U]) Iter(ctx context.Context, params Params) iter.Seq2[T, error]` | Iter returns an iterator over all resources across all pages. | `TestResource_Bad_IterError`, `TestResource_Good_Iter`, `TestResource_Good_IterBreakEarly` | +| method | Resource.List | `func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[T], error)` | List returns a single page of resources. | `TestResource_Good_List` | +| method | Resource.ListAll | `func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error)` | ListAll returns all resources across all pages. | `TestResource_Good_ListAll` | +| method | Resource.Update | `func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, body *U) (*T, error)` | Update modifies an existing resource. | `TestResource_Good_Update` | +| method | TeamService.AddMember | `func (s *TeamService) AddMember(ctx context.Context, teamID int64, username string) error` | AddMember adds a user to a team. | `TestTeamService_Good_AddMember` | +| method | TeamService.AddRepo | `func (s *TeamService) AddRepo(ctx context.Context, teamID int64, org, repo string) error` | AddRepo adds a repository to a team. | No direct tests. | +| method | TeamService.IterMembers | `func (s *TeamService) IterMembers(ctx context.Context, teamID int64) iter.Seq2[types.User, error]` | IterMembers returns an iterator over all members of a team. | No direct tests. | +| method | TeamService.IterOrgTeams | `func (s *TeamService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[types.Team, error]` | IterOrgTeams returns an iterator over all teams in an organisation. | No direct tests. | +| method | TeamService.IterRepos | `func (s *TeamService) IterRepos(ctx context.Context, teamID int64) iter.Seq2[types.Repository, error]` | IterRepos returns an iterator over all repositories managed by a team. | No direct tests. | +| method | TeamService.ListMembers | `func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error)` | ListMembers returns all members of a team. | `TestTeamService_Good_ListMembers` | +| method | TeamService.ListOrgTeams | `func (s *TeamService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error)` | ListOrgTeams returns all teams in an organisation. | No direct tests. | +| method | TeamService.ListRepos | `func (s *TeamService) ListRepos(ctx context.Context, teamID int64) ([]types.Repository, error)` | ListRepos returns all repositories managed by a team. | No direct tests. | +| method | TeamService.RemoveMember | `func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error` | RemoveMember removes a user from a team. | No direct tests. | +| method | TeamService.RemoveRepo | `func (s *TeamService) RemoveRepo(ctx context.Context, teamID int64, org, repo string) error` | RemoveRepo removes a repository from a team. | No direct tests. | +| method | UserService.Follow | `func (s *UserService) Follow(ctx context.Context, username string) error` | Follow follows a user as the authenticated user. | No direct tests. | +| method | UserService.CheckFollowing | `func (s *UserService) CheckFollowing(ctx context.Context, username, target string) (bool, error)` | CheckFollowing reports whether one user is following another user. | `TestUserService_CheckFollowing_Good`, `TestUserService_CheckFollowing_Bad_NotFound` | +| method | UserService.GetCurrent | `func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error)` | GetCurrent returns the authenticated user. | `TestUserService_Good_GetCurrent` | +| method | UserService.GetQuota | `func (s *UserService) GetQuota(ctx context.Context) (*types.QuotaInfo, error)` | GetQuota returns the authenticated user's quota information. | `TestUserService_GetQuota_Good` | +| method | UserService.ListQuotaArtifacts | `func (s *UserService) ListQuotaArtifacts(ctx context.Context) ([]types.QuotaUsedArtifact, error)` | ListQuotaArtifacts returns all artifacts affecting the authenticated user's quota. | `TestUserService_ListQuotaArtifacts_Good` | +| method | UserService.IterQuotaArtifacts | `func (s *UserService) IterQuotaArtifacts(ctx context.Context) iter.Seq2[types.QuotaUsedArtifact, error]` | IterQuotaArtifacts returns an iterator over all artifacts affecting the authenticated user's quota. | `TestUserService_IterQuotaArtifacts_Good` | +| method | UserService.ListQuotaAttachments | `func (s *UserService) ListQuotaAttachments(ctx context.Context) ([]types.QuotaUsedAttachment, error)` | ListQuotaAttachments returns all attachments affecting the authenticated user's quota. | `TestUserService_ListQuotaAttachments_Good` | +| method | UserService.IterQuotaAttachments | `func (s *UserService) IterQuotaAttachments(ctx context.Context) iter.Seq2[types.QuotaUsedAttachment, error]` | IterQuotaAttachments returns an iterator over all attachments affecting the authenticated user's quota. | `TestUserService_IterQuotaAttachments_Good` | +| method | UserService.ListQuotaPackages | `func (s *UserService) ListQuotaPackages(ctx context.Context) ([]types.QuotaUsedPackage, error)` | ListQuotaPackages returns all packages affecting the authenticated user's quota. | `TestUserService_ListQuotaPackages_Good` | +| method | UserService.IterQuotaPackages | `func (s *UserService) IterQuotaPackages(ctx context.Context) iter.Seq2[types.QuotaUsedPackage, error]` | IterQuotaPackages returns an iterator over all packages affecting the authenticated user's quota. | `TestUserService_IterQuotaPackages_Good` | +| method | UserService.ListStopwatches | `func (s *UserService) ListStopwatches(ctx context.Context) ([]types.StopWatch, error)` | ListStopwatches returns all existing stopwatches for the authenticated user. | `TestUserService_ListStopwatches_Good` | +| method | UserService.IterStopwatches | `func (s *UserService) IterStopwatches(ctx context.Context) iter.Seq2[types.StopWatch, error]` | IterStopwatches returns an iterator over all existing stopwatches for the authenticated user. | `TestUserService_IterStopwatches_Good` | +| method | UserService.IterFollowers | `func (s *UserService) IterFollowers(ctx context.Context, username string) iter.Seq2[types.User, error]` | IterFollowers returns an iterator over all followers of a user. | No direct tests. | +| method | UserService.IterFollowing | `func (s *UserService) IterFollowing(ctx context.Context, username string) iter.Seq2[types.User, error]` | IterFollowing returns an iterator over all users that a user is following. | No direct tests. | +| method | UserService.IterStarred | `func (s *UserService) IterStarred(ctx context.Context, username string) iter.Seq2[types.Repository, error]` | IterStarred returns an iterator over all repositories starred by a user. | No direct tests. | +| method | UserService.ListFollowers | `func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error)` | ListFollowers returns all followers of a user. | `TestUserService_Good_ListFollowers` | +| method | UserService.ListFollowing | `func (s *UserService) ListFollowing(ctx context.Context, username string) ([]types.User, error)` | ListFollowing returns all users that a user is following. | No direct tests. | +| method | UserService.ListStarred | `func (s *UserService) ListStarred(ctx context.Context, username string) ([]types.Repository, error)` | ListStarred returns all repositories starred by a user. | No direct tests. | +| method | UserService.Star | `func (s *UserService) Star(ctx context.Context, owner, repo string) error` | Star stars a repository as the authenticated user. | No direct tests. | +| method | UserService.Unfollow | `func (s *UserService) Unfollow(ctx context.Context, username string) error` | Unfollow unfollows a user as the authenticated user. | No direct tests. | +| method | UserService.Unstar | `func (s *UserService) Unstar(ctx context.Context, owner, repo string) error` | Unstar unstars a repository as the authenticated user. | No direct tests. | +| method | WebhookService.IterOrgHooks | `func (s *WebhookService) IterOrgHooks(ctx context.Context, org string) iter.Seq2[types.Hook, error]` | IterOrgHooks returns an iterator over all webhooks for an organisation. | No direct tests. | +| method | WebhookService.ListOrgHooks | `func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types.Hook, error)` | ListOrgHooks returns all webhooks for an organisation. | `TestWebhookService_Good_ListOrgHooks` | +| method | WebhookService.ListGitHooks | `func (s *WebhookService) ListGitHooks(ctx context.Context, owner, repo string) ([]types.GitHook, error)` | ListGitHooks returns all Git hooks for a repository. | `TestWebhookService_Good_ListGitHooks` | +| method | WebhookService.GetGitHook | `func (s *WebhookService) GetGitHook(ctx context.Context, owner, repo, id string) (*types.GitHook, error)` | GetGitHook returns a single Git hook for a repository. | `TestWebhookService_Good_GetGitHook` | +| method | WebhookService.EditGitHook | `func (s *WebhookService) EditGitHook(ctx context.Context, owner, repo, id string, opts *types.EditGitHookOption) (*types.GitHook, error)` | EditGitHook updates an existing Git hook in a repository. | `TestWebhookService_Good_EditGitHook` | +| method | WebhookService.DeleteGitHook | `func (s *WebhookService) DeleteGitHook(ctx context.Context, owner, repo, id string) error` | DeleteGitHook deletes a Git hook from a repository. | `TestWebhookService_Good_DeleteGitHook` | +| method | WebhookService.IterUserHooks | `func (s *WebhookService) IterUserHooks(ctx context.Context) iter.Seq2[types.Hook, error]` | IterUserHooks returns an iterator over all webhooks for the authenticated user. | No direct tests. | +| method | WebhookService.ListUserHooks | `func (s *WebhookService) ListUserHooks(ctx context.Context) ([]types.Hook, error)` | ListUserHooks returns all webhooks for the authenticated user. | `TestWebhookService_Good_ListUserHooks` | +| method | WebhookService.GetUserHook | `func (s *WebhookService) GetUserHook(ctx context.Context, id int64) (*types.Hook, error)` | GetUserHook returns a single webhook for the authenticated user. | `TestWebhookService_Good_GetUserHook` | +| method | WebhookService.CreateUserHook | `func (s *WebhookService) CreateUserHook(ctx context.Context, opts *types.CreateHookOption) (*types.Hook, error)` | CreateUserHook creates a webhook for the authenticated user. | `TestWebhookService_Good_CreateUserHook` | +| method | WebhookService.EditUserHook | `func (s *WebhookService) EditUserHook(ctx context.Context, id int64, opts *types.EditHookOption) (*types.Hook, error)` | EditUserHook updates an existing authenticated-user webhook. | `TestWebhookService_Good_EditUserHook` | +| method | WebhookService.DeleteUserHook | `func (s *WebhookService) DeleteUserHook(ctx context.Context, id int64) error` | DeleteUserHook deletes an authenticated-user webhook. | `TestWebhookService_Good_DeleteUserHook` | +| method | WebhookService.TestHook | `func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error` | TestHook triggers a test delivery for a webhook. | `TestWebhookService_Good_TestHook` | +| method | WikiService.CreatePage | `func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error)` | CreatePage creates a new wiki page. | `TestWikiService_Good_CreatePage` | +| method | WikiService.DeletePage | `func (s *WikiService) DeletePage(ctx context.Context, owner, repo, pageName string) error` | DeletePage removes a wiki page. | `TestWikiService_Good_DeletePage` | +| method | WikiService.EditPage | `func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error)` | EditPage updates an existing wiki page. | `TestWikiService_Good_EditPage` | +| method | WikiService.GetPage | `func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) (*types.WikiPage, error)` | GetPage returns a single wiki page by name. | `TestWikiService_Bad_NotFound`, `TestWikiService_Good_GetPage` | +| method | WikiService.ListPages | `func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]types.WikiPageMetaData, error)` | ListPages returns all wiki page metadata for a repository. | `TestWikiService_Good_ListPages` | + +## `forge/types` + +| Kind | Name | Signature | Description | Test Coverage | +| --- | --- | --- | --- | --- | +| type | APIError | `type APIError struct` | APIError is an api error with a message | `TestAPIError_Good_Error`, `TestClient_Bad_ServerError`, `TestIsConflict_Bad_NotConflict` (+2 more) | +| type | APIForbiddenError | `type APIForbiddenError struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | APIInvalidTopicsError | `type APIInvalidTopicsError struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | APINotFound | `type APINotFound struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | APIRepoArchivedError | `type APIRepoArchivedError struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | APIUnauthorizedError | `type APIUnauthorizedError struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | APIValidationError | `type APIValidationError struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | AccessToken | `type AccessToken struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | ActionTask | `type ActionTask struct` | ActionTask represents a ActionTask | No direct tests; only indirect coverage via callers. | +| type | ActionTaskResponse | `type ActionTaskResponse struct` | ActionTaskResponse returns a ActionTask | No direct tests; only indirect coverage via callers. | +| type | ActionVariable | `type ActionVariable struct` | ActionVariable return value of the query API | `TestActionsService_Good_ListOrgVariables`, `TestActionsService_Good_ListRepoVariables` | +| type | Activity | `type Activity struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | ActivityPub | `type ActivityPub struct` | ActivityPub type | No direct tests; only indirect coverage via callers. | +| type | AddCollaboratorOption | `type AddCollaboratorOption struct` | AddCollaboratorOption options when adding a user as a collaborator of a repository | No direct tests; only indirect coverage via callers. | +| type | AddTimeOption | `type AddTimeOption struct` | AddTimeOption options for adding time to an issue | No direct tests; only indirect coverage via callers. | +| type | AnnotatedTag | `type AnnotatedTag struct` | AnnotatedTag represents an annotated tag | No direct tests; only indirect coverage via callers. | +| type | AnnotatedTagObject | `type AnnotatedTagObject struct` | AnnotatedTagObject contains meta information of the tag object | No direct tests; only indirect coverage via callers. | +| type | Attachment | `type Attachment struct` | Attachment a generic attachment | No direct tests; only indirect coverage via callers. | +| type | BlockedUser | `type BlockedUser struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | Branch | `type Branch struct` | Branch represents a repository branch | `TestBranchService_Good_Get`, `TestBranchService_Good_List` | +| type | BranchProtection | `type BranchProtection struct` | BranchProtection represents a branch protection for a repository | `TestBranchService_Good_CreateProtection` | +| type | ChangeFileOperation | `type ChangeFileOperation struct` | ChangeFileOperation for creating, updating or deleting a file | No direct tests; only indirect coverage via callers. | +| type | ChangeFilesOptions | `type ChangeFilesOptions struct` | ChangeFilesOptions options for creating, updating or deleting multiple files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) | No direct tests; only indirect coverage via callers. | +| type | ChangedFile | `type ChangedFile struct` | ChangedFile store information about files affected by the pull request | No direct tests; only indirect coverage via callers. | +| type | CombinedStatus | `type CombinedStatus struct` | CombinedStatus holds the combined state of several statuses for a single commit | `TestCommitService_Good_GetCombinedStatus` | +| type | Comment | `type Comment struct` | Comment represents a comment on a commit or issue | `TestIssueService_Good_CreateComment` | +| type | Commit | `type Commit struct` | No doc comment. | `TestCommitService_Good_Get`, `TestCommitService_Good_GetNote`, `TestCommitService_Good_List` (+1 more) | +| type | CommitAffectedFiles | `type CommitAffectedFiles struct` | CommitAffectedFiles store information about files affected by the commit | No direct tests; only indirect coverage via callers. | +| type | CommitDateOptions | `type CommitDateOptions struct` | CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE | No direct tests; only indirect coverage via callers. | +| type | CommitMeta | `type CommitMeta struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | CommitStats | `type CommitStats struct` | CommitStats is statistics for a RepoCommit | No direct tests; only indirect coverage via callers. | +| type | CommitStatus | `type CommitStatus struct` | CommitStatus holds a single status of a single Commit | `TestCommitService_Good_CreateStatus`, `TestCommitService_Good_GetCombinedStatus`, `TestCommitService_Good_ListStatuses` | +| type | CommitStatusState | `type CommitStatusState struct` | CommitStatusState holds the state of a CommitStatus It can be "pending", "success", "error" and "failure" CommitStatusState has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | CommitUser | `type CommitUser struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | Compare | `type Compare struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | ContentsResponse | `type ContentsResponse struct` | ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content | `TestContentService_Good_CreateFile`, `TestContentService_Good_GetFile`, `TestContentService_Good_UpdateFile` | +| type | CreateAccessTokenOption | `type CreateAccessTokenOption struct` | CreateAccessTokenOption options when create access token | No direct tests; only indirect coverage via callers. | +| type | CreateBranchProtectionOption | `type CreateBranchProtectionOption struct` | CreateBranchProtectionOption options for creating a branch protection | `TestBranchService_Good_CreateProtection` | +| type | CreateBranchRepoOption | `type CreateBranchRepoOption struct` | CreateBranchRepoOption options when creating a branch in a repository | No direct tests; only indirect coverage via callers. | +| type | CreateEmailOption | `type CreateEmailOption struct` | CreateEmailOption options when creating email addresses | No direct tests; only indirect coverage via callers. | +| type | CreateFileOptions | `type CreateFileOptions struct` | CreateFileOptions options for creating files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) | `TestContentService_Good_CreateFile` | +| type | CreateForkOption | `type CreateForkOption struct` | CreateForkOption options for creating a fork | No direct tests; only indirect coverage via callers. | +| type | CreateGPGKeyOption | `type CreateGPGKeyOption struct` | CreateGPGKeyOption options create user GPG key | No direct tests; only indirect coverage via callers. | +| type | CreateHookOption | `type CreateHookOption struct` | CreateHookOption options when create a hook | `TestWebhookService_Good_Create` | +| type | CreateHookOptionConfig | `type CreateHookOptionConfig struct` | CreateHookOptionConfig has all config options in it required are "content_type" and "url" Required CreateHookOptionConfig has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | CreateIssueCommentOption | `type CreateIssueCommentOption struct` | CreateIssueCommentOption options for creating a comment on an issue | `TestIssueService_Good_CreateComment` | +| type | CreateIssueOption | `type CreateIssueOption struct` | CreateIssueOption options to create one issue | `TestIssueService_Good_Create` | +| type | CreateKeyOption | `type CreateKeyOption struct` | CreateKeyOption options when creating a key | No direct tests; only indirect coverage via callers. | +| type | CreateLabelOption | `type CreateLabelOption struct` | CreateLabelOption options for creating a label | `TestLabelService_Good_CreateOrgLabel`, `TestLabelService_Good_CreateRepoLabel` | +| type | CreateMilestoneOption | `type CreateMilestoneOption struct` | CreateMilestoneOption options for creating a milestone | No direct tests; only indirect coverage via callers. | +| type | CreateOAuth2ApplicationOptions | `type CreateOAuth2ApplicationOptions struct` | CreateOAuth2ApplicationOptions holds options to create an oauth2 application | No direct tests; only indirect coverage via callers. | +| type | CreateOrUpdateSecretOption | `type CreateOrUpdateSecretOption struct` | CreateOrUpdateSecretOption options when creating or updating secret | No direct tests; only indirect coverage via callers. | +| type | CreateOrgOption | `type CreateOrgOption struct` | CreateOrgOption options for creating an organization | No direct tests; only indirect coverage via callers. | +| type | CreatePullRequestOption | `type CreatePullRequestOption struct` | CreatePullRequestOption options when creating a pull request | `TestPullService_Good_Create` | +| type | CreatePullReviewComment | `type CreatePullReviewComment struct` | CreatePullReviewComment represent a review comment for creation api | No direct tests; only indirect coverage via callers. | +| type | CreatePullReviewCommentOptions | `type CreatePullReviewCommentOptions struct` | CreatePullReviewCommentOptions are options to create a pull review comment CreatePullReviewCommentOptions has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | CreatePullReviewOptions | `type CreatePullReviewOptions struct` | CreatePullReviewOptions are options to create a pull review | No direct tests; only indirect coverage via callers. | +| type | CreatePushMirrorOption | `type CreatePushMirrorOption struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | CreateQuotaGroupOptions | `type CreateQuotaGroupOptions struct` | CreateQutaGroupOptions represents the options for creating a quota group | No direct tests; only indirect coverage via callers. | +| type | CreateQuotaRuleOptions | `type CreateQuotaRuleOptions struct` | CreateQuotaRuleOptions represents the options for creating a quota rule | No direct tests; only indirect coverage via callers. | +| type | CreateReleaseOption | `type CreateReleaseOption struct` | CreateReleaseOption options when creating a release | No direct tests; only indirect coverage via callers. | +| type | CreateRepoOption | `type CreateRepoOption struct` | CreateRepoOption options when creating repository | No direct tests; only indirect coverage via callers. | +| type | CreateStatusOption | `type CreateStatusOption struct` | CreateStatusOption holds the information needed to create a new CommitStatus for a Commit | `TestCommitService_Good_CreateStatus` | +| type | CreateTagOption | `type CreateTagOption struct` | CreateTagOption options when creating a tag | No direct tests; only indirect coverage via callers. | +| type | CreateTagProtectionOption | `type CreateTagProtectionOption struct` | CreateTagProtectionOption options for creating a tag protection | No direct tests; only indirect coverage via callers. | +| type | CreateTeamOption | `type CreateTeamOption struct` | CreateTeamOption options for creating a team | No direct tests; only indirect coverage via callers. | +| type | CreateUserOption | `type CreateUserOption struct` | CreateUserOption create user options | `TestAdminService_Bad_CreateUser_Forbidden`, `TestAdminService_Good_CreateUser` | +| type | CreateVariableOption | `type CreateVariableOption struct` | CreateVariableOption the option when creating variable | `TestActionsService_Good_CreateRepoVariable` | +| type | CreateWikiPageOptions | `type CreateWikiPageOptions struct` | CreateWikiPageOptions form for creating wiki | `TestWikiService_Good_CreatePage`, `TestWikiService_Good_EditPage` | +| type | Cron | `type Cron struct` | Cron represents a Cron task | `TestAdminService_Good_ListCron` | +| type | DeleteEmailOption | `type DeleteEmailOption struct` | DeleteEmailOption options when deleting email addresses | No direct tests; only indirect coverage via callers. | +| type | DeleteFileOptions | `type DeleteFileOptions struct` | DeleteFileOptions options for deleting files (used for other File structs below) Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) | `TestContentService_Good_DeleteFile` | +| type | DeleteLabelsOption | `type DeleteLabelsOption struct` | DeleteLabelOption options for deleting a label | No direct tests; only indirect coverage via callers. | +| type | DeployKey | `type DeployKey struct` | DeployKey a deploy key | No direct tests; only indirect coverage via callers. | +| type | DismissPullReviewOptions | `type DismissPullReviewOptions struct` | DismissPullReviewOptions are options to dismiss a pull review | No direct tests; only indirect coverage via callers. | +| type | DispatchWorkflowOption | `type DispatchWorkflowOption struct` | DispatchWorkflowOption options when dispatching a workflow | No direct tests; only indirect coverage via callers. | +| type | EditAttachmentOptions | `type EditAttachmentOptions struct` | EditAttachmentOptions options for editing attachments | No direct tests; only indirect coverage via callers. | +| type | EditBranchProtectionOption | `type EditBranchProtectionOption struct` | EditBranchProtectionOption options for editing a branch protection | No direct tests; only indirect coverage via callers. | +| type | EditDeadlineOption | `type EditDeadlineOption struct` | EditDeadlineOption options for creating a deadline | No direct tests; only indirect coverage via callers. | +| type | EditGitHookOption | `type EditGitHookOption struct` | EditGitHookOption options when modifying one Git hook | No direct tests; only indirect coverage via callers. | +| type | EditHookOption | `type EditHookOption struct` | EditHookOption options when modify one hook | No direct tests; only indirect coverage via callers. | +| type | EditIssueCommentOption | `type EditIssueCommentOption struct` | EditIssueCommentOption options for editing a comment | No direct tests; only indirect coverage via callers. | +| type | EditIssueOption | `type EditIssueOption struct` | EditIssueOption options for editing an issue | `TestIssueService_Good_Update` | +| type | EditLabelOption | `type EditLabelOption struct` | EditLabelOption options for editing a label | `TestLabelService_Good_EditRepoLabel` | +| type | EditMilestoneOption | `type EditMilestoneOption struct` | EditMilestoneOption options for editing a milestone | No direct tests; only indirect coverage via callers. | +| type | EditOrgOption | `type EditOrgOption struct` | EditOrgOption options for editing an organization | No direct tests; only indirect coverage via callers. | +| type | EditPullRequestOption | `type EditPullRequestOption struct` | EditPullRequestOption options when modify pull request | No direct tests; only indirect coverage via callers. | +| type | EditQuotaRuleOptions | `type EditQuotaRuleOptions struct` | EditQuotaRuleOptions represents the options for editing a quota rule | No direct tests; only indirect coverage via callers. | +| type | EditReactionOption | `type EditReactionOption struct` | EditReactionOption contain the reaction type | No direct tests; only indirect coverage via callers. | +| type | EditReleaseOption | `type EditReleaseOption struct` | EditReleaseOption options when editing a release | No direct tests; only indirect coverage via callers. | +| type | EditRepoOption | `type EditRepoOption struct` | EditRepoOption options when editing a repository's properties | `TestRepoService_Good_Update` | +| type | EditTagProtectionOption | `type EditTagProtectionOption struct` | EditTagProtectionOption options for editing a tag protection | No direct tests; only indirect coverage via callers. | +| type | EditTeamOption | `type EditTeamOption struct` | EditTeamOption options for editing a team | No direct tests; only indirect coverage via callers. | +| type | EditUserOption | `type EditUserOption struct` | EditUserOption edit user options | No direct tests; only indirect coverage via callers. | +| type | Email | `type Email struct` | Email an email address belonging to a user | `TestAdminService_Bad_CreateUser_Forbidden`, `TestAdminService_Good_CreateUser` | +| type | ExternalTracker | `type ExternalTracker struct` | ExternalTracker represents settings for external tracker | No direct tests; only indirect coverage via callers. | +| type | ExternalWiki | `type ExternalWiki struct` | ExternalWiki represents setting for external wiki | No direct tests; only indirect coverage via callers. | +| type | FileCommitResponse | `type FileCommitResponse struct` | No doc comment. | `TestContentService_Good_CreateFile` | +| type | FileDeleteResponse | `type FileDeleteResponse struct` | FileDeleteResponse contains information about a repo's file that was deleted | `TestContentService_Good_DeleteFile` | +| type | FileLinksResponse | `type FileLinksResponse struct` | FileLinksResponse contains the links for a repo's file | No direct tests; only indirect coverage via callers. | +| type | FileResponse | `type FileResponse struct` | FileResponse contains information about a repo's file | `TestContentService_Good_CreateFile`, `TestContentService_Good_UpdateFile` | +| type | FilesResponse | `type FilesResponse struct` | FilesResponse contains information about multiple files from a repo | No direct tests; only indirect coverage via callers. | +| type | ForgeLike | `type ForgeLike struct` | ForgeLike activity data type ForgeLike has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | GPGKey | `type GPGKey struct` | GPGKey a user GPG key to sign commit and tag in repository | No direct tests; only indirect coverage via callers. | +| type | GPGKeyEmail | `type GPGKeyEmail struct` | GPGKeyEmail an email attached to a GPGKey | No direct tests; only indirect coverage via callers. | +| type | GeneralAPISettings | `type GeneralAPISettings struct` | GeneralAPISettings contains global api settings exposed by it | No direct tests; only indirect coverage via callers. | +| type | GeneralAttachmentSettings | `type GeneralAttachmentSettings struct` | GeneralAttachmentSettings contains global Attachment settings exposed by API | No direct tests; only indirect coverage via callers. | +| type | GeneralRepoSettings | `type GeneralRepoSettings struct` | GeneralRepoSettings contains global repository settings exposed by API | No direct tests; only indirect coverage via callers. | +| type | GeneralUISettings | `type GeneralUISettings struct` | GeneralUISettings contains global ui settings exposed by API | No direct tests; only indirect coverage via callers. | +| type | GenerateRepoOption | `type GenerateRepoOption struct` | GenerateRepoOption options when creating repository using a template | No direct tests; only indirect coverage via callers. | +| type | GitBlobResponse | `type GitBlobResponse struct` | GitBlobResponse represents a git blob | No direct tests; only indirect coverage via callers. | +| type | GitEntry | `type GitEntry struct` | GitEntry represents a git tree | No direct tests; only indirect coverage via callers. | +| type | GitHook | `type GitHook struct` | GitHook represents a Git repository hook | No direct tests; only indirect coverage via callers. | +| type | GitObject | `type GitObject struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | GitTreeResponse | `type GitTreeResponse struct` | GitTreeResponse returns a git tree | No direct tests; only indirect coverage via callers. | +| type | GitignoreTemplateInfo | `type GitignoreTemplateInfo struct` | GitignoreTemplateInfo name and text of a gitignore template | `TestMiscService_Good_GetGitignoreTemplate` | +| type | Hook | `type Hook struct` | Hook a hook is a web hook when one repository changed | `TestWebhookService_Good_Create`, `TestWebhookService_Good_Get`, `TestWebhookService_Good_List` (+2 more) | +| type | Identity | `type Identity struct` | Identity for a person's identity like an author or committer | No direct tests; only indirect coverage via callers. | +| type | InternalTracker | `type InternalTracker struct` | InternalTracker represents settings for internal tracker | No direct tests; only indirect coverage via callers. | +| type | Issue | `type Issue struct` | Issue represents an issue in a repository | `TestIssueService_Good_Create`, `TestIssueService_Good_Get`, `TestIssueService_Good_List` (+2 more) | +| type | IssueConfig | `type IssueConfig struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | IssueConfigContactLink | `type IssueConfigContactLink struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | IssueConfigValidation | `type IssueConfigValidation struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | IssueDeadline | `type IssueDeadline struct` | IssueDeadline represents an issue deadline | No direct tests; only indirect coverage via callers. | +| type | IssueFormField | `type IssueFormField struct` | IssueFormField represents a form field | No direct tests; only indirect coverage via callers. | +| type | IssueFormFieldType | `type IssueFormFieldType struct` | IssueFormFieldType has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | IssueFormFieldVisible | `type IssueFormFieldVisible struct` | IssueFormFieldVisible defines issue form field visible IssueFormFieldVisible has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | IssueLabelsOption | `type IssueLabelsOption struct` | IssueLabelsOption a collection of labels | No direct tests; only indirect coverage via callers. | +| type | IssueMeta | `type IssueMeta struct` | IssueMeta basic issue information | No direct tests; only indirect coverage via callers. | +| type | IssueTemplate | `type IssueTemplate struct` | IssueTemplate represents an issue template for a repository | No direct tests; only indirect coverage via callers. | +| type | IssueTemplateLabels | `type IssueTemplateLabels struct` | IssueTemplateLabels has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | Label | `type Label struct` | Label a label to an issue or a pr | `TestLabelService_Good_CreateOrgLabel`, `TestLabelService_Good_CreateRepoLabel`, `TestLabelService_Good_EditRepoLabel` (+3 more) | +| type | LabelTemplate | `type LabelTemplate struct` | LabelTemplate info of a Label template | No direct tests; only indirect coverage via callers. | +| type | LicenseTemplateInfo | `type LicenseTemplateInfo struct` | LicensesInfo contains information about a License | `TestMiscService_Good_GetLicense` | +| type | LicensesTemplateListEntry | `type LicensesTemplateListEntry struct` | LicensesListEntry is used for the API | `TestMiscService_Good_ListLicenses` | +| type | MarkdownOption | `type MarkdownOption struct` | MarkdownOption markdown options | `TestMiscService_Good_RenderMarkdown` | +| type | MarkupOption | `type MarkupOption struct` | MarkupOption markup options | No direct tests; only indirect coverage via callers. | +| type | MergePullRequestOption | `type MergePullRequestOption struct` | MergePullRequestForm form for merging Pull Request | No direct tests; only indirect coverage via callers. | +| type | MigrateRepoOptions | `type MigrateRepoOptions struct` | MigrateRepoOptions options for migrating repository's this is used to interact with api v1 | No direct tests; only indirect coverage via callers. | +| type | Milestone | `type Milestone struct` | Milestone milestone is a collection of issues on one repository | No direct tests; only indirect coverage via callers. | +| type | NewIssuePinsAllowed | `type NewIssuePinsAllowed struct` | NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed | No direct tests; only indirect coverage via callers. | +| type | NodeInfo | `type NodeInfo struct` | NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks | `TestMiscService_Good_GetNodeInfo` | +| type | NodeInfoServices | `type NodeInfoServices struct` | NodeInfoServices contains the third party sites this server can connect to via their application API | No direct tests; only indirect coverage via callers. | +| type | NodeInfoSoftware | `type NodeInfoSoftware struct` | NodeInfoSoftware contains Metadata about server software in use | `TestMiscService_Good_GetNodeInfo` | +| type | NodeInfoUsage | `type NodeInfoUsage struct` | NodeInfoUsage contains usage statistics for this server | No direct tests; only indirect coverage via callers. | +| type | NodeInfoUsageUsers | `type NodeInfoUsageUsers struct` | NodeInfoUsageUsers contains statistics about the users of this server | No direct tests; only indirect coverage via callers. | +| type | Note | `type Note struct` | Note contains information related to a git note | `TestCommitService_Good_GetNote` | +| type | NoteOptions | `type NoteOptions struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | NotificationCount | `type NotificationCount struct` | NotificationCount number of unread notifications | No direct tests; only indirect coverage via callers. | +| type | NotificationSubject | `type NotificationSubject struct` | NotificationSubject contains the notification subject (Issue/Pull/Commit) | `TestNotificationService_Good_GetThread`, `TestNotificationService_Good_List`, `TestNotificationService_Good_ListRepo` | +| type | NotificationThread | `type NotificationThread struct` | NotificationThread expose Notification on API | `TestNotificationService_Good_GetThread`, `TestNotificationService_Good_List`, `TestNotificationService_Good_ListRepo` | +| type | NotifySubjectType | `type NotifySubjectType struct` | NotifySubjectType represent type of notification subject NotifySubjectType has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | OAuth2Application | `type OAuth2Application struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | Organization | `type Organization struct` | Organization represents an organization | `TestAdminService_Good_ListOrgs`, `TestOrgService_Good_Get`, `TestOrgService_Good_List` | +| type | OrganizationPermissions | `type OrganizationPermissions struct` | OrganizationPermissions list different users permissions on an organization | No direct tests; only indirect coverage via callers. | +| type | PRBranchInfo | `type PRBranchInfo struct` | PRBranchInfo information about a branch | No direct tests; only indirect coverage via callers. | +| type | Package | `type Package struct` | Package represents a package | `TestPackageService_Good_Get`, `TestPackageService_Good_List` | +| type | PackageFile | `type PackageFile struct` | PackageFile represents a package file | `TestPackageService_Good_ListFiles` | +| type | PayloadCommit | `type PayloadCommit struct` | PayloadCommit represents a commit | No direct tests; only indirect coverage via callers. | +| type | PayloadCommitVerification | `type PayloadCommitVerification struct` | PayloadCommitVerification represents the GPG verification of a commit | No direct tests; only indirect coverage via callers. | +| type | PayloadUser | `type PayloadUser struct` | PayloadUser represents the author or committer of a commit | No direct tests; only indirect coverage via callers. | +| type | Permission | `type Permission struct` | Permission represents a set of permissions | No direct tests; only indirect coverage via callers. | +| type | PublicKey | `type PublicKey struct` | PublicKey publickey is a user key to push code to repository | No direct tests; only indirect coverage via callers. | +| type | PullRequest | `type PullRequest struct` | PullRequest represents a pull request | `TestPullService_Good_Create`, `TestPullService_Good_Get`, `TestPullService_Good_List` | +| type | PullRequestMeta | `type PullRequestMeta struct` | PullRequestMeta PR info if an issue is a PR | No direct tests; only indirect coverage via callers. | +| type | PullReview | `type PullReview struct` | PullReview represents a pull request review | No direct tests; only indirect coverage via callers. | +| type | PullReviewComment | `type PullReviewComment struct` | PullReviewComment represents a comment on a pull request review | No direct tests; only indirect coverage via callers. | +| type | PullReviewRequestOptions | `type PullReviewRequestOptions struct` | PullReviewRequestOptions are options to add or remove pull review requests | No direct tests; only indirect coverage via callers. | +| type | PushMirror | `type PushMirror struct` | PushMirror represents information of a push mirror | No direct tests; only indirect coverage via callers. | +| type | QuotaGroup | `type QuotaGroup struct` | QuotaGroup represents a quota group | No direct tests; only indirect coverage via callers. | +| type | QuotaGroupList | `type QuotaGroupList struct` | QuotaGroupList represents a list of quota groups QuotaGroupList has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | QuotaInfo | `type QuotaInfo struct` | QuotaInfo represents information about a user's quota | No direct tests; only indirect coverage via callers. | +| type | QuotaRuleInfo | `type QuotaRuleInfo struct` | QuotaRuleInfo contains information about a quota rule | No direct tests; only indirect coverage via callers. | +| type | QuotaUsed | `type QuotaUsed struct` | QuotaUsed represents the quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedArtifact | `type QuotaUsedArtifact struct` | QuotaUsedArtifact represents an artifact counting towards a user's quota | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedArtifactList | `type QuotaUsedArtifactList struct` | QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota QuotaUsedArtifactList has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedAttachment | `type QuotaUsedAttachment struct` | QuotaUsedAttachment represents an attachment counting towards a user's quota | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedAttachmentList | `type QuotaUsedAttachmentList struct` | QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota QuotaUsedAttachmentList has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedPackage | `type QuotaUsedPackage struct` | QuotaUsedPackage represents a package counting towards a user's quota | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedPackageList | `type QuotaUsedPackageList struct` | QuotaUsedPackageList represents a list of packages counting towards a user's quota QuotaUsedPackageList has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSize | `type QuotaUsedSize struct` | QuotaUsedSize represents the size-based quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSizeAssets | `type QuotaUsedSizeAssets struct` | QuotaUsedSizeAssets represents the size-based asset usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSizeAssetsAttachments | `type QuotaUsedSizeAssetsAttachments struct` | QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSizeAssetsPackages | `type QuotaUsedSizeAssetsPackages struct` | QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSizeGit | `type QuotaUsedSizeGit struct` | QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | QuotaUsedSizeRepos | `type QuotaUsedSizeRepos struct` | QuotaUsedSizeRepos represents the size-based repository quota usage of a user | No direct tests; only indirect coverage via callers. | +| type | Reaction | `type Reaction struct` | Reaction contain one reaction | No direct tests; only indirect coverage via callers. | +| type | Reference | `type Reference struct` | No doc comment. | No direct tests; only indirect coverage via callers. | +| type | Release | `type Release struct` | Release represents a repository release | `TestReleaseService_Good_Get`, `TestReleaseService_Good_GetByTag`, `TestReleaseService_Good_List` | +| type | RenameUserOption | `type RenameUserOption struct` | RenameUserOption options when renaming a user | `TestAdminService_Good_RenameUser` | +| type | ReplaceFlagsOption | `type ReplaceFlagsOption struct` | ReplaceFlagsOption options when replacing the flags of a repository | No direct tests; only indirect coverage via callers. | +| type | RepoCollaboratorPermission | `type RepoCollaboratorPermission struct` | RepoCollaboratorPermission to get repository permission for a collaborator | No direct tests; only indirect coverage via callers. | +| type | RepoCommit | `type RepoCommit struct` | No doc comment. | `TestCommitService_Good_Get`, `TestCommitService_Good_List` | +| type | RepoTopicOptions | `type RepoTopicOptions struct` | RepoTopicOptions a collection of repo topic names | No direct tests; only indirect coverage via callers. | +| type | RepoTransfer | `type RepoTransfer struct` | RepoTransfer represents a pending repo transfer | No direct tests; only indirect coverage via callers. | +| type | Repository | `type Repository struct` | Repository represents a repository | `TestRepoService_Good_Fork`, `TestRepoService_Good_Get`, `TestRepoService_Good_ListOrgRepos` (+1 more) | +| type | RepositoryMeta | `type RepositoryMeta struct` | RepositoryMeta basic repository information | No direct tests; only indirect coverage via callers. | +| type | ReviewStateType | `type ReviewStateType struct` | ReviewStateType review state type ReviewStateType has no fields in the swagger spec. | No direct tests; only indirect coverage via callers. | +| type | SearchResults | `type SearchResults struct` | SearchResults results of a successful search | No direct tests; only indirect coverage via callers. | +| type | Secret | `type Secret struct` | Secret represents a secret | `TestActionsService_Good_ListOrgSecrets`, `TestActionsService_Good_ListRepoSecrets` | +| type | ServerVersion | `type ServerVersion struct` | ServerVersion wraps the version of the server | `TestMiscService_Good_GetVersion` | +| type | SetUserQuotaGroupsOptions | `type SetUserQuotaGroupsOptions struct` | SetUserQuotaGroupsOptions represents the quota groups of a user | No direct tests; only indirect coverage via callers. | +| type | StateType | `type StateType string` | StateType is the state of an issue or PR: "open", "closed". | No direct tests; only indirect coverage via callers. | +| type | StopWatch | `type StopWatch struct` | StopWatch represent a running stopwatch | No direct tests; only indirect coverage via callers. | +| type | SubmitPullReviewOptions | `type SubmitPullReviewOptions struct` | SubmitPullReviewOptions are options to submit a pending pull review | No direct tests; only indirect coverage via callers. | +| type | Tag | `type Tag struct` | Tag represents a repository tag | No direct tests; only indirect coverage via callers. | +| type | TagArchiveDownloadCount | `type TagArchiveDownloadCount struct` | TagArchiveDownloadCount counts how many times a archive was downloaded | No direct tests; only indirect coverage via callers. | +| type | TagProtection | `type TagProtection struct` | TagProtection represents a tag protection | No direct tests; only indirect coverage via callers. | +| type | Team | `type Team struct` | Team represents a team in an organization | `TestTeamService_Good_Get` | +| type | TimeStamp | `type TimeStamp string` | TimeStamp is a Forgejo timestamp string. | No direct tests; only indirect coverage via callers. | +| type | TimelineComment | `type TimelineComment struct` | TimelineComment represents a timeline comment (comment of any type) on a commit or issue | No direct tests; only indirect coverage via callers. | +| type | TopicName | `type TopicName struct` | TopicName a list of repo topic names | No direct tests; only indirect coverage via callers. | +| type | TopicResponse | `type TopicResponse struct` | TopicResponse for returning topics | No direct tests; only indirect coverage via callers. | +| type | TrackedTime | `type TrackedTime struct` | TrackedTime worked time for an issue / pr | No direct tests; only indirect coverage via callers. | +| type | TransferRepoOption | `type TransferRepoOption struct` | TransferRepoOption options when transfer a repository's ownership | No direct tests; only indirect coverage via callers. | +| type | UpdateBranchRepoOption | `type UpdateBranchRepoOption struct` | UpdateBranchRepoOption options when updating a branch in a repository | No direct tests; only indirect coverage via callers. | +| type | UpdateFileOptions | `type UpdateFileOptions struct` | UpdateFileOptions options for updating files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) | `TestContentService_Good_UpdateFile` | +| type | UpdateRepoAvatarOption | `type UpdateRepoAvatarOption struct` | UpdateRepoAvatarUserOption options when updating the repo avatar | No direct tests; only indirect coverage via callers. | +| type | UpdateUserAvatarOption | `type UpdateUserAvatarOption struct` | UpdateUserAvatarUserOption options when updating the user avatar | No direct tests; only indirect coverage via callers. | +| type | UpdateVariableOption | `type UpdateVariableOption struct` | UpdateVariableOption the option when updating variable | No direct tests; only indirect coverage via callers. | +| type | User | `type User struct` | User represents a user | `TestAdminService_Good_CreateUser`, `TestAdminService_Good_ListUsers`, `TestOrgService_Good_ListMembers` (+4 more) | +| type | UserHeatmapData | `type UserHeatmapData struct` | UserHeatmapData represents the data needed to create a heatmap | No direct tests; only indirect coverage via callers. | +| type | UserSettings | `type UserSettings struct` | UserSettings represents user settings | No direct tests; only indirect coverage via callers. | +| type | UserSettingsOptions | `type UserSettingsOptions struct` | UserSettingsOptions represents options to change user settings | No direct tests; only indirect coverage via callers. | +| type | WatchInfo | `type WatchInfo struct` | WatchInfo represents an API watch status of one repository | No direct tests; only indirect coverage via callers. | +| type | WikiCommit | `type WikiCommit struct` | WikiCommit page commit/revision | No direct tests; only indirect coverage via callers. | +| type | WikiCommitList | `type WikiCommitList struct` | WikiCommitList commit/revision list | No direct tests; only indirect coverage via callers. | +| type | WikiPage | `type WikiPage struct` | WikiPage a wiki page | `TestWikiService_Good_CreatePage`, `TestWikiService_Good_EditPage`, `TestWikiService_Good_GetPage` | +| type | WikiPageMetaData | `type WikiPageMetaData struct` | WikiPageMetaData wiki page meta information | `TestWikiService_Good_ListPages` | + +## `cmd/forgegen` + +| Kind | Name | Signature | Description | Test Coverage | +| --- | --- | --- | --- | --- | +| type | CRUDPair | `type CRUDPair struct` | CRUDPair groups a base type with its corresponding Create and Edit option types. | No direct tests. | +| type | GoField | `type GoField struct` | GoField is the intermediate representation for a single struct field. | No direct tests. | +| type | GoType | `type GoType struct` | GoType is the intermediate representation for a Go type to be generated. | `TestParser_Good_FieldTypes` | +| type | SchemaDefinition | `type SchemaDefinition struct` | SchemaDefinition represents a single type definition in the swagger spec. | No direct tests. | +| type | SchemaProperty | `type SchemaProperty struct` | SchemaProperty represents a single property within a schema definition. | No direct tests. | +| type | Spec | `type Spec struct` | Spec represents a Swagger 2.0 specification document. | No direct tests. | +| type | SpecInfo | `type SpecInfo struct` | SpecInfo holds metadata about the API specification. | No direct tests. | +| function | DetectCRUDPairs | `func DetectCRUDPairs(spec *Spec) []CRUDPair` | DetectCRUDPairs finds Create*Option / Edit*Option pairs in the swagger definitions and maps them back to the base type name. | `TestGenerate_Good_CreatesFiles`, `TestGenerate_Good_RepositoryType`, `TestGenerate_Good_TimeImport` (+2 more) | +| function | ExtractTypes | `func ExtractTypes(spec *Spec) map[string]*GoType` | ExtractTypes converts all swagger definitions into Go type intermediate representations. | `TestGenerate_Good_CreatesFiles`, `TestGenerate_Good_RepositoryType`, `TestGenerate_Good_TimeImport` (+3 more) | +| function | Generate | `func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error` | Generate writes Go source files for the extracted types, grouped by logical domain. | `TestGenerate_Good_CreatesFiles`, `TestGenerate_Good_RepositoryType`, `TestGenerate_Good_TimeImport` (+1 more) | +| function | LoadSpec | `func LoadSpec(path string) (*Spec, error)` | LoadSpec reads and parses a Swagger 2.0 JSON file from the given path. | `TestGenerate_Good_CreatesFiles`, `TestGenerate_Good_RepositoryType`, `TestGenerate_Good_TimeImport` (+5 more) | diff --git a/docs/architecture.md b/docs/architecture.md index 3b1162c..ee9c270 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ go-forge is organised in three layers, each building on the one below: ``` ┌─────────────────────────────────────────────────┐ │ Forge (top-level client) │ -│ Aggregates 18 service structs │ +│ Aggregates 20 service structs │ ├─────────────────────────────────────────────────┤ │ Service layer │ │ RepoService, IssueService, PullService, ... │ @@ -49,6 +49,7 @@ func (c *Client) Delete(ctx context.Context, path string) error func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error) +func (c *Client) HTTPClient() *http.Client ``` The `Raw` variants return the response body as `[]byte` instead of decoding JSON. This is used by endpoints that return non-JSON content (e.g. the markdown rendering endpoint returns raw HTML). diff --git a/docs/development.md b/docs/development.md index ae9f377..2f6ca36 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,7 +39,7 @@ All tests use the standard `testing` package with `net/http/httptest` for HTTP s go test ./... # Run a specific test by name -go test -v -run TestClient_Good_Get ./... +go test -v -run TestClient_Get_Good ./... # Run tests with race detection go test -race ./... @@ -59,7 +59,7 @@ core go cov --open # Open coverage report in browser ### Test naming convention -Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern: +Tests follow `Test__`: - **`_Good`** — Happy-path tests confirming correct behaviour. - **`_Bad`** — Expected error conditions (e.g. 404, 500 responses). @@ -67,11 +67,11 @@ Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern: Examples: ``` -TestClient_Good_Get -TestClient_Bad_ServerError -TestClient_Bad_NotFound -TestClient_Good_ContextCancellation -TestResource_Good_ListAll +TestClient_Get_Good +TestClient_ServerError_Bad +TestClient_NotFound_Bad +TestClient_ContextCancellation_Good +TestResource_ListAll_Good ``` @@ -173,7 +173,7 @@ To add coverage for a new Forgejo API domain: ```go func (s *TopicService) ListRepoTopics(ctx context.Context, owner, repo string) ([]types.Topic, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/topics", pathParams("owner", owner, "repo", repo)) return ListAll[types.Topic](ctx, s.client, path, nil) } ``` diff --git a/docs/index.md b/docs/index.md index 18e5d58..a88b5ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Full-coverage Go client for the Forgejo API with generics-based CRU # go-forge -`dappco.re/go/core/forge` is a Go client library for the [Forgejo](https://forgejo.org) REST API. It provides typed access to 18 API domains (repositories, issues, pull requests, organisations, and more) through a single top-level `Forge` client. Types are generated directly from Forgejo's `swagger.v1.json` specification, keeping the library in lockstep with the server. +`dappco.re/go/core/forge` is a Go client library for the [Forgejo](https://forgejo.org) REST API. It provides typed access to 20 API domains (repositories, issues, pull requests, organisations, milestones, ActivityPub, and more) through a single top-level `Forge` client. Types are generated directly from Forgejo's `swagger.v1.json` specification, keeping the library in lockstep with the server. **Module path:** `dappco.re/go/core/forge` **Go version:** 1.26+ @@ -75,7 +75,7 @@ Environment variables: go-forge/ ├── client.go HTTP client, auth, error handling, rate limits ├── config.go Config resolution: flags > env > defaults -├── forge.go Top-level Forge struct aggregating all 18 services +├── forge.go Top-level Forge struct aggregating all 20 services ├── resource.go Generic Resource[T, C, U] for CRUD operations ├── pagination.go ListPage, ListAll, ListIter — paginated requests ├── params.go Path variable resolution ({owner}/{repo} -> values) @@ -92,11 +92,13 @@ go-forge/ ├── webhooks.go WebhookService — repo and org webhooks ├── notifications.go NotificationService — notifications, threads ├── packages.go PackageService — package registry -├── actions.go ActionsService — CI/CD secrets, variables, dispatches +├── actions.go ActionsService — CI/CD secrets, variables, dispatches, tasks ├── contents.go ContentService — file read/write/delete ├── wiki.go WikiService — wiki pages -├── commits.go CommitService — statuses, notes ├── misc.go MiscService — markdown, licences, gitignore, version +├── commits.go CommitService — statuses, notes +├── milestones.go MilestoneService — repository milestones +├── activitypub.go ActivityPubService — ActivityPub actors and inboxes ├── types/ 229 generated Go types from swagger.v1.json │ ├── generate.go go:generate directive │ ├── repo.go Repository, CreateRepoOption, EditRepoOption, ... @@ -114,7 +116,7 @@ go-forge/ ## Services -The `Forge` struct exposes 18 service fields, each handling a different API domain: +The `Forge` struct exposes 20 service fields, each handling a different API domain: | Service | Struct | Embedding | Domain | |-----------------|---------------------|----------------------------------|--------------------------------------| @@ -131,18 +133,20 @@ The `Forge` struct exposes 18 service fields, each handling a different API doma | `Webhooks` | `WebhookService` | `Resource[Hook, ...]` | Repo and org webhooks | | `Notifications` | `NotificationService` | (standalone) | Notifications, threads | | `Packages` | `PackageService` | (standalone) | Package registry | -| `Actions` | `ActionsService` | (standalone) | CI/CD secrets, variables, dispatches | +| `Actions` | `ActionsService` | (standalone) | CI/CD secrets, variables, dispatches, tasks | | `Contents` | `ContentService` | (standalone) | File read/write/delete | | `Wiki` | `WikiService` | (standalone) | Wiki pages | -| `Commits` | `CommitService` | (standalone) | Commit statuses, git notes | | `Misc` | `MiscService` | (standalone) | Markdown, licences, gitignore, version | +| `Commits` | `CommitService` | (standalone) | Commit statuses, git notes | +| `Milestones` | `MilestoneService` | (standalone) | Repository milestones | +| `ActivityPub` | `ActivityPubService` | (standalone) | ActivityPub actors and inboxes | Services that embed `Resource[T, C, U]` inherit `List`, `ListAll`, `Iter`, `Get`, `Create`, `Update`, and `Delete` methods automatically. Standalone services have hand-written methods because their API endpoints are heterogeneous and do not fit a uniform CRUD pattern. ## Dependencies -This module has **zero external dependencies**. It relies solely on the Go standard library (`net/http`, `encoding/json`, `context`, `iter`, etc.) and requires Go 1.26 or later. +This module has a small dependency set: `dappco.re/go/core` and `github.com/goccy/go-json`, plus the Go standard library (`net/http`, `context`, `iter`, etc.) where appropriate. ``` module dappco.re/go/core/forge diff --git a/forge.go b/forge.go index c65689a..e24eb8b 100644 --- a/forge.go +++ b/forge.go @@ -1,6 +1,14 @@ package forge +import "net/http" + // Forge is the top-level client for the Forgejo API. +// +// Usage: +// +// ctx := context.Background() +// f := forge.NewForge("https://forge.lthn.ai", "token") +// repo, err := f.Repos.Get(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type Forge struct { client *Client @@ -22,9 +30,17 @@ type Forge struct { Wiki *WikiService Misc *MiscService Commits *CommitService + Milestones *MilestoneService + ActivityPub *ActivityPubService } // NewForge creates a new Forge client. +// +// Usage: +// +// ctx := context.Background() +// f := forge.NewForge("https://forge.lthn.ai", "token") +// repos, err := f.Repos.ListOrgRepos(ctx, "core") func NewForge(url, token string, opts ...Option) *Forge { c := NewClient(url, token, opts...) f := &Forge{client: c} @@ -46,8 +62,103 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Wiki = newWikiService(c) f.Misc = newMiscService(c) f.Commits = newCommitService(c) + f.Milestones = newMilestoneService(c) + f.ActivityPub = newActivityPubService(c) return f } -// Client returns the underlying HTTP client. -func (f *Forge) Client() *Client { return f.client } +// Client returns the underlying Forge client. +// +// Usage: +// +// client := f.Client() +func (f *Forge) Client() *Client { + if f == nil { + return nil + } + return f.client +} + +// BaseURL returns the configured Forgejo base URL. +// +// Usage: +// +// baseURL := f.BaseURL() +func (f *Forge) BaseURL() string { + if f == nil || f.client == nil { + return "" + } + return f.client.BaseURL() +} + +// RateLimit returns the last known rate limit information. +// +// Usage: +// +// rl := f.RateLimit() +func (f *Forge) RateLimit() RateLimit { + if f == nil || f.client == nil { + return RateLimit{} + } + return f.client.RateLimit() +} + +// UserAgent returns the configured User-Agent header value. +// +// Usage: +// +// ua := f.UserAgent() +func (f *Forge) UserAgent() string { + if f == nil || f.client == nil { + return "" + } + return f.client.UserAgent() +} + +// HTTPClient returns the configured underlying HTTP client. +// +// Usage: +// +// hc := f.HTTPClient() +func (f *Forge) HTTPClient() *http.Client { + if f == nil || f.client == nil { + return nil + } + return f.client.HTTPClient() +} + +// HasToken reports whether the Forge client was configured with an API token. +// +// Usage: +// +// if f.HasToken() { +// _ = "authenticated" +// } +func (f *Forge) HasToken() bool { + if f == nil || f.client == nil { + return false + } + return f.client.HasToken() +} + +// String returns a safe summary of the Forge client. +// +// Usage: +// +// s := f.String() +func (f *Forge) String() string { + if f == nil { + return "forge.Forge{}" + } + if f.client == nil { + return "forge.Forge{client=}" + } + return "forge.Forge{client=" + f.client.String() + "}" +} + +// GoString returns a safe Go-syntax summary of the Forge client. +// +// Usage: +// +// s := fmt.Sprintf("%#v", f) +func (f *Forge) GoString() string { return f.String() } diff --git a/forge_test.go b/forge_test.go index c13feaa..78e3792 100644 --- a/forge_test.go +++ b/forge_test.go @@ -1,8 +1,10 @@ package forge import ( + "bytes" "context" - "encoding/json" + "fmt" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +12,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestForge_Good_NewForge(t *testing.T) { +func TestForge_NewForge_Good(t *testing.T) { f := NewForge("https://forge.lthn.ai", "tok") if f.Repos == nil { t.Fatal("Repos service is nil") @@ -18,9 +20,12 @@ func TestForge_Good_NewForge(t *testing.T) { if f.Issues == nil { t.Fatal("Issues service is nil") } + if f.ActivityPub == nil { + t.Fatal("ActivityPub service is nil") + } } -func TestForge_Good_Client(t *testing.T) { +func TestForge_Client_Good(t *testing.T) { f := NewForge("https://forge.lthn.ai", "tok") c := f.Client() if c == nil { @@ -29,27 +34,123 @@ func TestForge_Good_Client(t *testing.T) { if c.baseURL != "https://forge.lthn.ai" { t.Errorf("got baseURL=%q", c.baseURL) } + if got := c.BaseURL(); got != "https://forge.lthn.ai" { + t.Errorf("got BaseURL()=%q", got) + } +} + +func TestForge_BaseURL_Good(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok") + if got := f.BaseURL(); got != "https://forge.lthn.ai" { + t.Fatalf("got base URL %q", got) + } +} + +func TestForge_RateLimit_Good(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok") + if got := f.RateLimit(); got != (RateLimit{}) { + t.Fatalf("got rate limit %#v", got) + } +} + +func TestForge_UserAgent_Good(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok", WithUserAgent("go-forge/1.0")) + if got := f.UserAgent(); got != "go-forge/1.0" { + t.Fatalf("got user agent %q", got) + } +} + +func TestForge_HTTPClient_Good(t *testing.T) { + custom := &http.Client{} + f := NewForge("https://forge.lthn.ai", "tok", WithHTTPClient(custom)) + if got := f.HTTPClient(); got != custom { + t.Fatal("expected HTTPClient() to return the configured HTTP client") + } +} + +func TestForge_HasToken_Good(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok") + if !f.HasToken() { + t.Fatal("expected HasToken to report configured token") + } +} + +func TestForge_HasToken_Bad(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "") + if f.HasToken() { + t.Fatal("expected HasToken to report missing token") + } +} + +func TestForge_NilSafeAccessors(t *testing.T) { + var f *Forge + if got := f.Client(); got != nil { + t.Fatal("expected Client() to return nil") + } + if got := f.BaseURL(); got != "" { + t.Fatalf("got BaseURL()=%q, want empty string", got) + } + if got := f.RateLimit(); got != (RateLimit{}) { + t.Fatalf("got RateLimit()=%#v, want zero value", got) + } + if got := f.UserAgent(); got != "" { + t.Fatalf("got UserAgent()=%q, want empty string", got) + } + if got := f.HTTPClient(); got != nil { + t.Fatal("expected HTTPClient() to return nil") + } + if got := f.HasToken(); got { + t.Fatal("expected HasToken() to report false") + } +} + +func TestForge_String_Good(t *testing.T) { + f := NewForge("https://forge.lthn.ai", "tok", WithUserAgent("go-forge/1.0")) + got := fmt.Sprint(f) + want := `forge.Forge{client=forge.Client{baseURL="https://forge.lthn.ai", token=set, userAgent="go-forge/1.0"}}` + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := f.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", f); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } } -func TestRepoService_Good_List(t *testing.T) { +func TestRepoService_ListOrgRepos_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } w.Header().Set("X-Total-Count", "1") json.NewEncoder(w).Encode([]types.Repository{{Name: "go-forge"}}) })) defer srv.Close() f := NewForge(srv.URL, "tok") - result, err := f.Repos.List(context.Background(), Params{"org": "core"}, DefaultList) + repos, err := f.Repos.ListOrgRepos(context.Background(), "core") if err != nil { t.Fatal(err) } - if len(result.Items) != 1 || result.Items[0].Name != "go-forge" { - t.Errorf("unexpected result: %+v", result) + if len(repos) != 1 || repos[0].Name != "go-forge" { + t.Errorf("unexpected result: %+v", repos) } } -func TestRepoService_Good_Get(t *testing.T) { +func TestRepoService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/core/go-forge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", FullName: "core/go-forge"}) })) defer srv.Close() @@ -64,7 +165,68 @@ func TestRepoService_Good_Get(t *testing.T) { } } -func TestRepoService_Good_Fork(t *testing.T) { +func TestRepoService_Update_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditRepoOption + json.NewDecoder(r.Body).Decode(&body) + json.NewEncoder(w).Encode(types.Repository{Name: body.Name, FullName: "core/" + body.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.Update(context.Background(), Params{"owner": "core", "repo": "go-forge"}, &types.EditRepoOption{ + Name: "go-forge-renamed", + }) + if err != nil { + t.Fatal(err) + } + if repo.Name != "go-forge-renamed" { + t.Errorf("got name=%q", repo.Name) + } +} + +func TestRepoService_Delete_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.Delete(context.Background(), Params{"owner": "core", "repo": "go-forge"}); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_Get_Bad(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Repos.Get(context.Background(), Params{"owner": "core", "repo": "go-forge"}); !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestRepoService_Fork_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -83,3 +245,80 @@ func TestRepoService_Good_Fork(t *testing.T) { t.Error("expected fork=true") } } + +func TestRepoService_GetArchive_Good(t *testing.T) { + want := []byte("zip-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/archive/master.zip" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(want) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + got, err := f.Repos.GetArchive(context.Background(), "core", "go-forge", "master.zip") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRepoService_GetRawFile_Good(t *testing.T) { + want := []byte("# go-forge\n\nA Go client for Forgejo.") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/raw/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(want) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + got, err := f.Repos.GetRawFile(context.Background(), "core", "go-forge", "README.md") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRepoService_ListTags_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tags" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Tag{{Name: "v1.0.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tags, err := f.Repos.ListTags(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(tags) != 1 || tags[0].Name != "v1.0.0" { + t.Fatalf("unexpected result: %+v", tags) + } +} diff --git a/go.mod b/go.mod index f973b82..b3a3c6b 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module dappco.re/go/core/forge go 1.26.0 require ( + dappco.re/go/core v0.4.7 dappco.re/go/core/io v0.2.0 - dappco.re/go/core/log v0.1.0 + github.com/goccy/go-json v0.10.6 ) -require forge.lthn.ai/core/go-log v0.0.4 // indirect +require dappco.re/go/core/log v0.0.4 // indirect diff --git a/go.sum b/go.sum index 76d01ec..8169f53 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,13 @@ +dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= +dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= -dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..4c15c90 --- /dev/null +++ b/helpers.go @@ -0,0 +1,121 @@ +package forge + +import ( + "fmt" + "strconv" + "strings" + "time" + + core "dappco.re/go/core" +) + +func trimTrailingSlashes(s string) string { + for core.HasSuffix(s, "/") { + s = core.TrimSuffix(s, "/") + } + return s +} + +func int64String(v int64) string { + return strconv.FormatInt(v, 10) +} + +func pathParams(values ...string) Params { + params := make(Params, len(values)/2) + for i := 0; i+1 < len(values); i += 2 { + params[values[i]] = values[i+1] + } + return params +} + +func optionString(typeName string, fields ...any) string { + var b strings.Builder + b.WriteString(typeName) + b.WriteString("{") + + wroteField := false + for i := 0; i+1 < len(fields); i += 2 { + name, _ := fields[i].(string) + value := fields[i+1] + if isZeroOptionValue(value) { + continue + } + if wroteField { + b.WriteString(", ") + } + wroteField = true + b.WriteString(name) + b.WriteString("=") + b.WriteString(formatOptionValue(value)) + } + + b.WriteString("}") + return b.String() +} + +func isZeroOptionValue(v any) bool { + switch x := v.(type) { + case nil: + return true + case string: + return x == "" + case bool: + return !x + case int: + return x == 0 + case int64: + return x == 0 + case []string: + return len(x) == 0 + case *time.Time: + return x == nil + case *bool: + return x == nil + case time.Time: + return x.IsZero() + default: + return false + } +} + +func formatOptionValue(v any) string { + switch x := v.(type) { + case string: + return strconv.Quote(x) + case bool: + return strconv.FormatBool(x) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case []string: + return fmt.Sprintf("%#v", x) + case *time.Time: + if x == nil { + return "" + } + return strconv.Quote(x.Format(time.RFC3339)) + case *bool: + if x == nil { + return "" + } + return strconv.FormatBool(*x) + case time.Time: + return strconv.Quote(x.Format(time.RFC3339)) + default: + return fmt.Sprintf("%#v", v) + } +} + +func serviceString(typeName, fieldName string, value any) string { + return typeName + "{" + fieldName + "=" + fmt.Sprint(value) + "}" +} + +func lastIndexByte(s string, b byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == b { + return i + } + } + return -1 +} diff --git a/issues.go b/issues.go index a6ab01d..b26c4a9 100644 --- a/issues.go +++ b/issues.go @@ -2,17 +2,156 @@ package forge import ( "context" - "fmt" "iter" + "strconv" + "time" + + goio "io" "dappco.re/go/core/forge/types" ) // IssueService handles issue operations within a repository. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Issues.ListAll(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type IssueService struct { Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption] } +// IssueListOptions controls filtering for repository issue listings. +// +// Usage: +// +// opts := forge.IssueListOptions{State: "open", Labels: "bug"} +type IssueListOptions struct { + State string + Labels string + Query string + Type string + Milestones string + Since *time.Time + Before *time.Time + CreatedBy string + AssignedBy string + MentionedBy string +} + +// String returns a safe summary of the issue list filters. +func (o IssueListOptions) String() string { + return optionString("forge.IssueListOptions", + "state", o.State, + "labels", o.Labels, + "q", o.Query, + "type", o.Type, + "milestones", o.Milestones, + "since", o.Since, + "before", o.Before, + "created_by", o.CreatedBy, + "assigned_by", o.AssignedBy, + "mentioned_by", o.MentionedBy, + ) +} + +// GoString returns a safe Go-syntax summary of the issue list filters. +func (o IssueListOptions) GoString() string { return o.String() } + +func (o IssueListOptions) queryParams() map[string]string { + query := make(map[string]string, 10) + if o.State != "" { + query["state"] = o.State + } + if o.Labels != "" { + query["labels"] = o.Labels + } + if o.Query != "" { + query["q"] = o.Query + } + if o.Type != "" { + query["type"] = o.Type + } + if o.Milestones != "" { + query["milestones"] = o.Milestones + } + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if o.CreatedBy != "" { + query["created_by"] = o.CreatedBy + } + if o.AssignedBy != "" { + query["assigned_by"] = o.AssignedBy + } + if o.MentionedBy != "" { + query["mentioned_by"] = o.MentionedBy + } + if len(query) == 0 { + return nil + } + return query +} + +// AttachmentUploadOptions controls metadata sent when uploading an attachment. +// +// Usage: +// +// opts := forge.AttachmentUploadOptions{Name: "screenshot.png"} +type AttachmentUploadOptions struct { + Name string + UpdatedAt *time.Time +} + +// String returns a safe summary of the attachment upload metadata. +func (o AttachmentUploadOptions) String() string { + return optionString("forge.AttachmentUploadOptions", + "name", o.Name, + "updated_at", o.UpdatedAt, + ) +} + +// GoString returns a safe Go-syntax summary of the attachment upload metadata. +func (o AttachmentUploadOptions) GoString() string { return o.String() } + +// RepoCommentListOptions controls filtering for repository-wide issue comment listings. +// +// Usage: +// +// opts := forge.RepoCommentListOptions{Page: 1, Limit: 50} +type RepoCommentListOptions struct { + Since *time.Time + Before *time.Time +} + +// String returns a safe summary of the repository comment filters. +func (o RepoCommentListOptions) String() string { + return optionString("forge.RepoCommentListOptions", + "since", o.Since, + "before", o.Before, + ) +} + +// GoString returns a safe Go-syntax summary of the repository comment filters. +func (o RepoCommentListOptions) GoString() string { return o.String() } + +func (o RepoCommentListOptions) queryParams() map[string]string { + query := make(map[string]string, 2) + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if len(query) == 0 { + return nil + } + return query +} + func newIssueService(c *Client) *IssueService { return &IssueService{ Resource: *NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption]( @@ -21,79 +160,285 @@ func newIssueService(c *Client) *IssueService { } } +// SearchIssuesOptions controls filtering for the global issue search endpoint. +// +// Usage: +// +// opts := forge.SearchIssuesOptions{State: "open"} +type SearchIssuesOptions struct { + State string + Labels string + Milestones string + Query string + PriorityRepoID int64 + Type string + Since *time.Time + Before *time.Time + Assigned bool + Created bool + Mentioned bool + ReviewRequested bool + Reviewed bool + Owner string + Team string +} + +// String returns a safe summary of the issue search filters. +func (o SearchIssuesOptions) String() string { + return optionString("forge.SearchIssuesOptions", + "state", o.State, + "labels", o.Labels, + "milestones", o.Milestones, + "q", o.Query, + "priority_repo_id", o.PriorityRepoID, + "type", o.Type, + "since", o.Since, + "before", o.Before, + "assigned", o.Assigned, + "created", o.Created, + "mentioned", o.Mentioned, + "review_requested", o.ReviewRequested, + "reviewed", o.Reviewed, + "owner", o.Owner, + "team", o.Team, + ) +} + +// GoString returns a safe Go-syntax summary of the issue search filters. +func (o SearchIssuesOptions) GoString() string { return o.String() } + +func (o SearchIssuesOptions) queryParams() map[string]string { + query := make(map[string]string, 12) + if o.State != "" { + query["state"] = o.State + } + if o.Labels != "" { + query["labels"] = o.Labels + } + if o.Milestones != "" { + query["milestones"] = o.Milestones + } + if o.Query != "" { + query["q"] = o.Query + } + if o.PriorityRepoID != 0 { + query["priority_repo_id"] = strconv.FormatInt(o.PriorityRepoID, 10) + } + if o.Type != "" { + query["type"] = o.Type + } + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if o.Assigned { + query["assigned"] = strconv.FormatBool(true) + } + if o.Created { + query["created"] = strconv.FormatBool(true) + } + if o.Mentioned { + query["mentioned"] = strconv.FormatBool(true) + } + if o.ReviewRequested { + query["review_requested"] = strconv.FormatBool(true) + } + if o.Reviewed { + query["reviewed"] = strconv.FormatBool(true) + } + if o.Owner != "" { + query["owner"] = o.Owner + } + if o.Team != "" { + query["team"] = o.Team + } + if len(query) == 0 { + return nil + } + return query +} + +// SearchIssuesPage returns a single page of issues matching the search filters. +func (s *IssueService) SearchIssuesPage(ctx context.Context, opts SearchIssuesOptions, pageOpts ListOptions) (*PagedResult[types.Issue], error) { + return ListPage[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams(), pageOpts) +} + +// SearchIssues returns all issues matching the search filters. +func (s *IssueService) SearchIssues(ctx context.Context, opts SearchIssuesOptions) ([]types.Issue, error) { + return ListAll[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) +} + +// IterSearchIssues returns an iterator over issues matching the search filters. +func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOptions) iter.Seq2[types.Issue, error] { + return ListIter[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) +} + +// ListIssues returns all issues in a repository. +func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) ([]types.Issue, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Issue](ctx, s.client, path, issueListQuery(filters...)) +} + +// IterIssues returns an iterator over all issues in a repository. +func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) iter.Seq2[types.Issue, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) +} + +// CreateIssue creates a new issue in a repository. +func (s *IssueService) CreateIssue(ctx context.Context, owner, repo string, opts *types.CreateIssueOption) (*types.Issue, error) { + var out types.Issue + if err := s.client.Post(ctx, ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)), opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // Pin pins an issue. func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return s.client.Post(ctx, path, nil, nil) } +// MovePin moves a pinned issue to a new position. +func (s *IssueService) MovePin(ctx context.Context, owner, repo string, index, position int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin/{position}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "position", int64String(position))) + return s.client.Patch(ctx, path, nil, nil) +} + +// ListPinnedIssues returns all pinned issues in a repository. +func (s *IssueService) ListPinnedIssues(ctx context.Context, owner, repo string) ([]types.Issue, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/pinned", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Issue](ctx, s.client, path, nil) +} + +// IterPinnedIssues returns an iterator over all pinned issues in a repository. +func (s *IssueService) IterPinnedIssues(ctx context.Context, owner, repo string) iter.Seq2[types.Issue, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/pinned", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Issue](ctx, s.client, path, nil) +} + // Unpin unpins an issue. func (s *IssueService) Unpin(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return s.client.Delete(ctx, path) } // SetDeadline sets or updates the deadline on an issue. func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/deadline", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/deadline", pathParams("owner", owner, "repo", repo, "index", int64String(index))) body := map[string]string{"due_date": deadline} return s.client.Post(ctx, path, body, nil) } // AddReaction adds a reaction to an issue. func (s *IssueService) AddReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) - body := map[string]string{"content": reaction} + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + body := types.EditReactionOption{Reaction: reaction} return s.client.Post(ctx, path, body, nil) } +// ListReactions returns all reactions on an issue. +func (s *IssueService) ListReactions(ctx context.Context, owner, repo string, index int64) ([]types.Reaction, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.Reaction](ctx, s.client, path, nil) +} + +// IterReactions returns an iterator over all reactions on an issue. +func (s *IssueService) IterReactions(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Reaction, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.Reaction](ctx, s.client, path, nil) +} + // DeleteReaction removes a reaction from an issue. func (s *IssueService) DeleteReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) - body := map[string]string{"content": reaction} + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + body := types.EditReactionOption{Reaction: reaction} return s.client.DeleteWithBody(ctx, path, body) } // StartStopwatch starts the stopwatch on an issue. func (s *IssueService) StartStopwatch(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/start", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/stopwatch/start", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return s.client.Post(ctx, path, nil, nil) } // StopStopwatch stops the stopwatch on an issue. func (s *IssueService) StopStopwatch(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/stop", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/stopwatch/stop", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return s.client.Post(ctx, path, nil, nil) } +// DeleteStopwatch deletes an issue's existing stopwatch. +func (s *IssueService) DeleteStopwatch(ctx context.Context, owner, repo string, index int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/stopwatch/delete", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.Delete(ctx, path) +} + +// ListTimes returns all tracked times on an issue. +func (s *IssueService) ListTimes(ctx context.Context, owner, repo string, index int64, user string, since, before *time.Time) ([]types.TrackedTime, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/times", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.TrackedTime](ctx, s.client, path, issueTimeQuery(user, since, before)) +} + +// IterTimes returns an iterator over all tracked times on an issue. +func (s *IssueService) IterTimes(ctx context.Context, owner, repo string, index int64, user string, since, before *time.Time) iter.Seq2[types.TrackedTime, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/times", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.TrackedTime](ctx, s.client, path, issueTimeQuery(user, since, before)) +} + +// AddTime adds tracked time to an issue. +func (s *IssueService) AddTime(ctx context.Context, owner, repo string, index int64, opts *types.AddTimeOption) (*types.TrackedTime, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/times", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + var out types.TrackedTime + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ResetTime removes all tracked time from an issue. +func (s *IssueService) ResetTime(ctx context.Context, owner, repo string, index int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/times", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.Delete(ctx, path) +} + +// DeleteTime removes a specific tracked time entry from an issue. +func (s *IssueService) DeleteTime(ctx context.Context, owner, repo string, index, timeID int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/times/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(timeID))) + return s.client.Delete(ctx, path) +} + // AddLabels adds labels to an issue. func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels", pathParams("owner", owner, "repo", repo, "index", int64String(index))) body := types.IssueLabelsOption{Labels: toAnySlice(labelIDs)} return s.client.Post(ctx, path, body, nil) } // RemoveLabel removes a single label from an issue. func (s *IssueService) RemoveLabel(ctx context.Context, owner, repo string, index int64, labelID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, labelID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(labelID))) return s.client.Delete(ctx, path) } // ListComments returns all comments on an issue. func (s *IssueService) ListComments(ctx context.Context, owner, repo string, index int64) ([]types.Comment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListAll[types.Comment](ctx, s.client, path, nil) } // IterComments returns an iterator over all comments on an issue. func (s *IssueService) IterComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.Comment](ctx, s.client, path, nil) } // CreateComment creates a comment on an issue. func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, index int64, body string) (*types.Comment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) opts := types.CreateIssueCommentOption{Body: body} var out types.Comment if err := s.client.Post(ctx, path, opts, &out); err != nil { @@ -102,6 +447,328 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +// EditComment updates an issue comment. +func (s *IssueService) EditComment(ctx context.Context, owner, repo string, index, id int64, opts *types.EditIssueCommentOption) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(id))) + var out types.Comment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteComment deletes an issue comment. +func (s *IssueService) DeleteComment(ctx context.Context, owner, repo string, index, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListRepoComments returns all comments in a repository. +func (s *IssueService) ListRepoComments(ctx context.Context, owner, repo string, filters ...RepoCommentListOptions) ([]types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Comment](ctx, s.client, path, repoCommentQuery(filters...)) +} + +// IterRepoComments returns an iterator over all comments in a repository. +func (s *IssueService) IterRepoComments(ctx context.Context, owner, repo string, filters ...RepoCommentListOptions) iter.Seq2[types.Comment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Comment](ctx, s.client, path, repoCommentQuery(filters...)) +} + +// GetRepoComment returns a single comment in a repository. +func (s *IssueService) GetRepoComment(ctx context.Context, owner, repo string, id int64) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Comment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditRepoComment updates a repository comment. +func (s *IssueService) EditRepoComment(ctx context.Context, owner, repo string, id int64, opts *types.EditIssueCommentOption) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Comment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteRepoComment deletes a repository comment. +func (s *IssueService) DeleteRepoComment(ctx context.Context, owner, repo string, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListCommentReactions returns all reactions on an issue comment. +func (s *IssueService) ListCommentReactions(ctx context.Context, owner, repo string, id int64) ([]types.Reaction, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/reactions", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return ListAll[types.Reaction](ctx, s.client, path, nil) +} + +// IterCommentReactions returns an iterator over all reactions on an issue comment. +func (s *IssueService) IterCommentReactions(ctx context.Context, owner, repo string, id int64) iter.Seq2[types.Reaction, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/reactions", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return ListIter[types.Reaction](ctx, s.client, path, nil) +} + +// AddCommentReaction adds a reaction to an issue comment. +func (s *IssueService) AddCommentReaction(ctx context.Context, owner, repo string, id int64, reaction string) (*types.Reaction, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/reactions", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Reaction + if err := s.client.Post(ctx, path, types.EditReactionOption{Reaction: reaction}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteCommentReaction removes a reaction from an issue comment. +func (s *IssueService) DeleteCommentReaction(ctx context.Context, owner, repo string, id int64, reaction string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/reactions", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.DeleteWithBody(ctx, path, types.EditReactionOption{Reaction: reaction}) +} + +func issueListQuery(filters ...IssueListOptions) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} + +func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { + if opts == nil { + return nil + } + query := make(map[string]string, 2) + if opts.Name != "" { + query["name"] = opts.Name + } + if opts.UpdatedAt != nil { + query["updated_at"] = opts.UpdatedAt.Format(time.RFC3339) + } + if len(query) == 0 { + return nil + } + return query +} + +func (s *IssueService) createAttachment(ctx context.Context, path string, opts *AttachmentUploadOptions, filename string, content goio.Reader) (*types.Attachment, error) { + var out types.Attachment + if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), nil, "attachment", filename, content, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateAttachment uploads a new attachment to an issue. +func (s *IssueService) CreateAttachment(ctx context.Context, owner, repo string, index int64, opts *AttachmentUploadOptions, filename string, content goio.Reader) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.createAttachment(ctx, path, opts, filename, content) +} + +// ListAttachments returns all attachments on an issue. +func (s *IssueService) ListAttachments(ctx context.Context, owner, repo string, index int64) ([]types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.Attachment](ctx, s.client, path, nil) +} + +// IterAttachments returns an iterator over all attachments on an issue. +func (s *IssueService) IterAttachments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Attachment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.Attachment](ctx, s.client, path, nil) +} + +// GetAttachment returns a single attachment on an issue. +func (s *IssueService) GetAttachment(ctx context.Context, owner, repo string, index, attachmentID int64) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "attachment_id", int64String(attachmentID))) + var out types.Attachment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditAttachment updates an issue attachment. +func (s *IssueService) EditAttachment(ctx context.Context, owner, repo string, index, attachmentID int64, opts *types.EditAttachmentOptions) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "attachment_id", int64String(attachmentID))) + var out types.Attachment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteAttachment removes an issue attachment. +func (s *IssueService) DeleteAttachment(ctx context.Context, owner, repo string, index, attachmentID int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "attachment_id", int64String(attachmentID))) + return s.client.Delete(ctx, path) +} + +// ListCommentAttachments returns all attachments on an issue comment. +func (s *IssueService) ListCommentAttachments(ctx context.Context, owner, repo string, id int64) ([]types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return ListAll[types.Attachment](ctx, s.client, path, nil) +} + +// IterCommentAttachments returns an iterator over all attachments on an issue comment. +func (s *IssueService) IterCommentAttachments(ctx context.Context, owner, repo string, id int64) iter.Seq2[types.Attachment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return ListIter[types.Attachment](ctx, s.client, path, nil) +} + +// GetCommentAttachment returns a single attachment on an issue comment. +func (s *IssueService) GetCommentAttachment(ctx context.Context, owner, repo string, id, attachmentID int64) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "id", int64String(id), "attachment_id", int64String(attachmentID))) + var out types.Attachment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateCommentAttachment uploads a new attachment to an issue comment. +func (s *IssueService) CreateCommentAttachment(ctx context.Context, owner, repo string, id int64, opts *AttachmentUploadOptions, filename string, content goio.Reader) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.createAttachment(ctx, path, opts, filename, content) +} + +// EditCommentAttachment updates an issue comment attachment. +func (s *IssueService) EditCommentAttachment(ctx context.Context, owner, repo string, id, attachmentID int64, opts *types.EditAttachmentOptions) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "id", int64String(id), "attachment_id", int64String(attachmentID))) + var out types.Attachment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteCommentAttachment removes an issue comment attachment. +func (s *IssueService) DeleteCommentAttachment(ctx context.Context, owner, repo string, id, attachmentID int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "id", int64String(id), "attachment_id", int64String(attachmentID))) + return s.client.Delete(ctx, path) +} + +// ListTimeline returns all comments and events on an issue. +func (s *IssueService) ListTimeline(ctx context.Context, owner, repo string, index int64, since, before *time.Time) ([]types.TimelineComment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/timeline", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + query := make(map[string]string, 2) + if since != nil { + query["since"] = since.Format(time.RFC3339) + } + if before != nil { + query["before"] = before.Format(time.RFC3339) + } + if len(query) == 0 { + query = nil + } + return ListAll[types.TimelineComment](ctx, s.client, path, query) +} + +// IterTimeline returns an iterator over all comments and events on an issue. +func (s *IssueService) IterTimeline(ctx context.Context, owner, repo string, index int64, since, before *time.Time) iter.Seq2[types.TimelineComment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/timeline", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + query := make(map[string]string, 2) + if since != nil { + query["since"] = since.Format(time.RFC3339) + } + if before != nil { + query["before"] = before.Format(time.RFC3339) + } + if len(query) == 0 { + query = nil + } + return ListIter[types.TimelineComment](ctx, s.client, path, query) +} + +// ListSubscriptions returns all users subscribed to an issue. +func (s *IssueService) ListSubscriptions(ctx context.Context, owner, repo string, index int64) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/subscriptions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterSubscriptions returns an iterator over all users subscribed to an issue. +func (s *IssueService) IterSubscriptions(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/subscriptions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// CheckSubscription returns the authenticated user's subscription state for an issue. +func (s *IssueService) CheckSubscription(ctx context.Context, owner, repo string, index int64) (*types.WatchInfo, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/subscriptions/check", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + var out types.WatchInfo + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SubscribeUser subscribes a user to an issue. +func (s *IssueService) SubscribeUser(ctx context.Context, owner, repo string, index int64, user string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/subscriptions/{user}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "user", user)) + return s.client.Put(ctx, path, nil, nil) +} + +// UnsubscribeUser unsubscribes a user from an issue. +func (s *IssueService) UnsubscribeUser(ctx context.Context, owner, repo string, index int64, user string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/subscriptions/{user}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "user", user)) + return s.client.Delete(ctx, path) +} + +// ListDependencies returns all issues that block the given issue. +func (s *IssueService) ListDependencies(ctx context.Context, owner, repo string, index int64) ([]types.Issue, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/dependencies", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.Issue](ctx, s.client, path, nil) +} + +// IterDependencies returns an iterator over all issues that block the given issue. +func (s *IssueService) IterDependencies(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Issue, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/dependencies", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.Issue](ctx, s.client, path, nil) +} + +// AddDependency makes another issue block the issue at the given path. +func (s *IssueService) AddDependency(ctx context.Context, owner, repo string, index int64, dependency types.IssueMeta) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/dependencies", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.Post(ctx, path, dependency, nil) +} + +// RemoveDependency removes an issue dependency from the issue at the given path. +func (s *IssueService) RemoveDependency(ctx context.Context, owner, repo string, index int64, dependency types.IssueMeta) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/dependencies", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.DeleteWithBody(ctx, path, dependency) +} + +// ListBlocks returns all issues blocked by the given issue. +func (s *IssueService) ListBlocks(ctx context.Context, owner, repo string, index int64) ([]types.Issue, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/blocks", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.Issue](ctx, s.client, path, nil) +} + +// IterBlocks returns an iterator over all issues blocked by the given issue. +func (s *IssueService) IterBlocks(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Issue, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/blocks", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.Issue](ctx, s.client, path, nil) +} + +// AddBlock makes the issue at the given path block another issue. +func (s *IssueService) AddBlock(ctx context.Context, owner, repo string, index int64, blockedIssue types.IssueMeta) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/blocks", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.Post(ctx, path, blockedIssue, nil) +} + +// RemoveBlock removes an issue block from the issue at the given path. +func (s *IssueService) RemoveBlock(ctx context.Context, owner, repo string, index int64, blockedIssue types.IssueMeta) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/blocks", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.DeleteWithBody(ctx, path, blockedIssue) +} + // toAnySlice converts a slice of int64 to a slice of any for IssueLabelsOption. func toAnySlice(ids []int64) []any { out := make([]any, len(ids)) @@ -110,3 +777,37 @@ func toAnySlice(ids []int64) []any { } return out } + +func repoCommentQuery(filters ...RepoCommentListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 2) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} + +func issueTimeQuery(user string, since, before *time.Time) map[string]string { + query := make(map[string]string, 3) + if user != "" { + query["user"] = user + } + if since != nil { + query["since"] = since.Format(time.RFC3339) + } + if before != nil { + query["before"] = before.Format(time.RFC3339) + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/issues_extra_test.go b/issues_extra_test.go new file mode 100644 index 0000000..27d942d --- /dev/null +++ b/issues_extra_test.go @@ -0,0 +1,63 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestIssueService_ListIssues_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 1, Title: "bug"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListIssues(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Title != "bug" { + t.Fatalf("got %#v", issues) + } +} + +func TestIssueService_CreateIssue_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateIssueOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Title != "new issue" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.Issue{ID: 1, Index: 1, Title: body.Title}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issue, err := f.Issues.CreateIssue(context.Background(), "core", "go-forge", &types.CreateIssueOption{Title: "new issue"}) + if err != nil { + t.Fatal(err) + } + if issue.Title != "new issue" { + t.Fatalf("got title=%q", issue.Title) + } +} diff --git a/issues_test.go b/issues_test.go index 8ad5fbf..38f8f21 100644 --- a/issues_test.go +++ b/issues_test.go @@ -1,20 +1,62 @@ package forge import ( + "bytes" "context" - "encoding/json" + json "github.com/goccy/go-json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" + "reflect" "testing" + "time" "dappco.re/go/core/forge/types" ) -func TestIssueService_Good_List(t *testing.T) { +func readMultipartAttachment(t *testing.T, r *http.Request) (string, string) { + t.Helper() + + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatal(err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("got content-type=%q", mediaType) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + part, err := reader.NextPart() + if err != nil { + t.Fatal(err) + } + if part.FormName() != "attachment" { + t.Fatalf("got form name=%q", part.FormName()) + } + content, err := io.ReadAll(part) + if err != nil { + t.Fatal(err) + } + return part.FileName(), string(content) +} + +func TestIssueService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } w.Header().Set("X-Total-Count", "2") json.NewEncoder(w).Encode([]types.Issue{ {ID: 1, Title: "bug report"}, @@ -36,7 +78,65 @@ func TestIssueService_Good_List(t *testing.T) { } } -func TestIssueService_Good_Get(t *testing.T) { +func TestIssueService_ListFiltered_Good(t *testing.T) { + since := time.Date(2026, time.March, 1, 12, 30, 0, 0, time.UTC) + before := time.Date(2026, time.March, 2, 12, 30, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "labels": "bug,help wanted", + "q": "panic", + "type": "issues", + "milestones": "v1.0", + "since": since.Format(time.RFC3339), + "before": before.Format(time.RFC3339), + "created_by": "alice", + "assigned_by": "bob", + "mentioned_by": "carol", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 1, Title: "panic in parser"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListIssues(context.Background(), "core", "go-forge", IssueListOptions{ + State: "open", + Labels: "bug,help wanted", + Query: "panic", + Type: "issues", + Milestones: "v1.0", + Since: &since, + Before: &before, + CreatedBy: "alice", + AssignedBy: "bob", + MentionedBy: "carol", + }) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Title != "panic in parser" { + t.Fatalf("got %#v", issues) + } +} + +func TestIssueService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -58,11 +158,16 @@ func TestIssueService_Good_Get(t *testing.T) { } } -func TestIssueService_Good_Create(t *testing.T) { +func TestIssueService_Create_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } var body types.CreateIssueOption json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(http.StatusCreated) @@ -83,21 +188,1381 @@ func TestIssueService_Good_Create(t *testing.T) { } } -func TestIssueService_Good_Pin(t *testing.T) { +func TestIssueService_Update_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditIssueOption + json.NewDecoder(r.Body).Decode(&body) + json.NewEncoder(w).Encode(types.Issue{ID: 1, Title: body.Title, Index: 1}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issue, err := f.Issues.Update(context.Background(), Params{"owner": "core", "repo": "go-forge", "index": "1"}, &types.EditIssueOption{ + Title: "updated issue", + }) + if err != nil { + t.Fatal(err) + } + if issue.Title != "updated issue" { + t.Errorf("got title=%q", issue.Title) + } +} + +func TestIssueService_Delete_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.Delete(context.Background(), Params{"owner": "core", "repo": "go-forge", "index": "1"}); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_SearchIssuesPage_Good(t *testing.T) { + since := time.Date(2026, time.March, 1, 12, 30, 0, 0, time.UTC) + before := time.Date(2026, time.March, 2, 12, 30, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "labels": "bug,help wanted", + "milestones": "v1.0", + "q": "panic", + "priority_repo_id": "42", + "type": "issues", + "since": since.Format(time.RFC3339), + "before": before.Format(time.RFC3339), + "assigned": "true", + "created": "true", + "mentioned": "true", + "review_requested": "true", + "reviewed": "true", + "owner": "core", + "team": "platform", + "page": "2", + "limit": "25", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "100") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "panic in parser"}, + {ID: 2, Title: "panic in generator"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Issues.SearchIssuesPage(context.Background(), SearchIssuesOptions{ + State: "open", + Labels: "bug,help wanted", + Milestones: "v1.0", + Query: "panic", + PriorityRepoID: 42, + Type: "issues", + Since: &since, + Before: &before, + Assigned: true, + Created: true, + Mentioned: true, + ReviewRequested: true, + Reviewed: true, + Owner: "core", + Team: "platform", + }, ListOptions{Page: 2, Limit: 25}) + if err != nil { + t.Fatal(err) + } + if got, want := len(page.Items), 2; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if !page.HasMore { + t.Fatalf("expected HasMore to be true") + } + if page.TotalCount != 100 { + t.Fatalf("got total count %d, want 100", page.TotalCount) + } + if page.Items[0].Title != "panic in parser" { + t.Fatalf("got first title %q", page.Items[0].Title) + } +} + +func TestIssueService_SearchIssues_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "panic" { + t.Errorf("got q=%q, want %q", got, "panic") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "panic in parser"}, + {ID: 2, Title: "panic in generator"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.SearchIssues(context.Background(), SearchIssuesOptions{Query: "panic"}) + if err != nil { + t.Fatal(err) + } + if got, want := len(issues), 2; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if issues[1].Title != "panic in generator" { + t.Fatalf("got second title %q", issues[1].Title) + } +} + +func TestIssueService_IterSearchIssues_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "panic" { + t.Errorf("got q=%q, want %q", got, "panic") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 1, Title: "panic in parser"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.Issue + for issue, err := range f.Issues.IterSearchIssues(context.Background(), SearchIssuesOptions{Query: "panic"}) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, issue) + } + if got, want := len(seen), 1; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if seen[0].Title != "panic in parser" { + t.Fatalf("got title %q", seen[0].Title) + } +} + +func TestIssueService_CreateComment_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } - if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/pin" { + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/comments" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.CreateIssueCommentOption + json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Comment{ID: 7, Body: body.Body}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comment, err := f.Issues.CreateComment(context.Background(), "core", "go-forge", 1, "first!") + if err != nil { + t.Fatal(err) + } + if comment.Body != "first!" { + t.Errorf("got body=%q", comment.Body) + } +} + +func TestIssueService_ListRepoComments_Good(t *testing.T) { + since := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC) + before := time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Comment{ + {ID: 7, Body: "repo-wide comment"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comments, err := f.Issues.ListRepoComments(context.Background(), "core", "go-forge", RepoCommentListOptions{ + Since: &since, + Before: &before, + }) + if err != nil { + t.Fatal(err) + } + if len(comments) != 1 || comments[0].Body != "repo-wide comment" { + t.Fatalf("unexpected result: %#v", comments) + } +} + +func TestIssueService_GetRepoComment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Comment{ID: 7, Body: "repo-wide comment"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comment, err := f.Issues.GetRepoComment(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if comment.Body != "repo-wide comment" { + t.Fatalf("got body=%q", comment.Body) + } +} + +func TestIssueService_EditRepoComment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditIssueCommentOption + json.NewDecoder(r.Body).Decode(&body) + if body.Body != "updated comment" { + t.Fatalf("got body=%#v", body) + } + json.NewEncoder(w).Encode(types.Comment{ID: 7, Body: body.Body}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + comment, err := f.Issues.EditRepoComment(context.Background(), "core", "go-forge", 7, &types.EditIssueCommentOption{ + Body: "updated comment", + }) + if err != nil { + t.Fatal(err) + } + if comment.Body != "updated comment" { + t.Fatalf("got body=%q", comment.Body) + } +} + +func TestIssueService_DeleteRepoComment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7" { t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() f := NewForge(srv.URL, "tok") - err := f.Issues.Pin(context.Background(), "core", "go-forge", 42) + if err := f.Issues.DeleteRepoComment(context.Background(), "core", "go-forge", 7); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListReactions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/reactions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Reaction{ + {Reaction: "+1", User: &types.User{ID: 1, UserName: "alice"}}, + {Reaction: "heart", User: &types.User{ID: 2, UserName: "bob"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + reactions, err := f.Issues.ListReactions(context.Background(), "core", "go-forge", 1) if err != nil { t.Fatal(err) } + if !reflect.DeepEqual(reactions, []types.Reaction{ + {Reaction: "+1", User: &types.User{ID: 1, UserName: "alice"}}, + {Reaction: "heart", User: &types.User{ID: 2, UserName: "bob"}}, + }) { + t.Fatalf("got %#v", reactions) + } +} + +func TestIssueService_IterReactions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/reactions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Reaction{ + {Reaction: "+1", User: &types.User{ID: 1, UserName: "alice"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.Reaction + for reaction, err := range f.Issues.IterReactions(context.Background(), "core", "go-forge", 1) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, reaction) + } + if !reflect.DeepEqual(seen, []types.Reaction{{Reaction: "+1", User: &types.User{ID: 1, UserName: "alice"}}}) { + t.Fatalf("got %#v", seen) + } +} + +func TestIssueService_ListCommentReactions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7/reactions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Reaction{ + {Reaction: "eyes", User: &types.User{ID: 3, UserName: "carol"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + reactions, err := f.Issues.ListCommentReactions(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reactions, []types.Reaction{ + {Reaction: "eyes", User: &types.User{ID: 3, UserName: "carol"}}, + }) { + t.Fatalf("got %#v", reactions) + } +} + +func TestIssueService_AddCommentReaction_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7/reactions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditReactionOption + json.NewDecoder(r.Body).Decode(&body) + if body.Reaction != "heart" { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Reaction{Reaction: body.Reaction, User: &types.User{ID: 4, UserName: "dave"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + reaction, err := f.Issues.AddCommentReaction(context.Background(), "core", "go-forge", 7, "heart") + if err != nil { + t.Fatal(err) + } + if reaction.Reaction != "heart" || reaction.User.UserName != "dave" { + t.Fatalf("got %#v", reaction) + } +} + +func TestIssueService_DeleteCommentReaction_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7/reactions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditReactionOption + json.NewDecoder(r.Body).Decode(&body) + if body.Reaction != "heart" { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.DeleteCommentReaction(context.Background(), "core", "go-forge", 7, "heart"); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListAttachments_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Attachment{ + {ID: 4, Name: "design.png"}, + {ID: 5, Name: "notes.txt"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachments, err := f.Issues.ListAttachments(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(attachments, []types.Attachment{{ID: 4, Name: "design.png"}, {ID: 5, Name: "notes.txt"}}) { + t.Fatalf("got %#v", attachments) + } +} + +func TestIssueService_IterAttachments_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Attachment{{ID: 4, Name: "design.png"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.Attachment + for attachment, err := range f.Issues.IterAttachments(context.Background(), "core", "go-forge", 1) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, attachment) + } + if !reflect.DeepEqual(seen, []types.Attachment{{ID: 4, Name: "design.png"}}) { + t.Fatalf("got %#v", seen) + } +} + +func TestIssueService_GetAttachment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets/4" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Attachment{ID: 4, Name: "design.png"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Issues.GetAttachment(context.Background(), "core", "go-forge", 1, 4) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "design.png" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestIssueService_EditAttachment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets/4" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditAttachmentOptions + json.NewDecoder(r.Body).Decode(&body) + if body.Name != "updated.png" { + t.Fatalf("got body=%#v", body) + } + json.NewEncoder(w).Encode(types.Attachment{ID: 4, Name: body.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Issues.EditAttachment(context.Background(), "core", "go-forge", 1, 4, &types.EditAttachmentOptions{Name: "updated.png"}) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "updated.png" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestIssueService_DeleteAttachment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets/4" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.DeleteAttachment(context.Background(), "core", "go-forge", 1, 4); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_CreateAttachment_Good(t *testing.T) { + updatedAt := time.Date(2026, time.March, 3, 11, 22, 33, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("name"); got != "diagram" { + t.Fatalf("got name=%q", got) + } + if got := r.URL.Query().Get("updated_at"); got != updatedAt.Format(time.RFC3339) { + t.Fatalf("got updated_at=%q", got) + } + filename, content := readMultipartAttachment(t, r) + if filename != "design.png" { + t.Fatalf("got filename=%q", filename) + } + if content != "attachment bytes" { + t.Fatalf("got content=%q", content) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Attachment{ID: 9, Name: filename}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Issues.CreateAttachment( + context.Background(), + "core", + "go-forge", + 1, + &AttachmentUploadOptions{Name: "diagram", UpdatedAt: &updatedAt}, + "design.png", + bytes.NewBufferString("attachment bytes"), + ) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "design.png" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestIssueService_CreateCommentAttachment_Good(t *testing.T) { + updatedAt := time.Date(2026, time.March, 4, 9, 10, 11, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/comments/7/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("name"); got != "screenshot" { + t.Fatalf("got name=%q", got) + } + if got := r.URL.Query().Get("updated_at"); got != updatedAt.Format(time.RFC3339) { + t.Fatalf("got updated_at=%q", got) + } + filename, content := readMultipartAttachment(t, r) + if filename != "comment.png" { + t.Fatalf("got filename=%q", filename) + } + if content != "comment attachment bytes" { + t.Fatalf("got content=%q", content) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Attachment{ID: 11, Name: filename}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Issues.CreateCommentAttachment( + context.Background(), + "core", + "go-forge", + 7, + &AttachmentUploadOptions{Name: "screenshot", UpdatedAt: &updatedAt}, + "comment.png", + bytes.NewBufferString("comment attachment bytes"), + ) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "comment.png" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestIssueService_ListTimeline_Good(t *testing.T) { + since := time.Date(2026, time.March, 1, 12, 30, 0, 0, time.UTC) + before := time.Date(2026, time.March, 2, 12, 30, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/timeline" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.TimelineComment{ + {ID: 11, Type: "comment", Body: "first"}, + {ID: 12, Type: "state_change", Body: "second"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + events, err := f.Issues.ListTimeline(context.Background(), "core", "go-forge", 1, &since, &before) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(events, []types.TimelineComment{ + {ID: 11, Type: "comment", Body: "first"}, + {ID: 12, Type: "state_change", Body: "second"}, + }) { + t.Fatalf("got %#v", events) + } +} + +func TestIssueService_IterTimeline_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/timeline" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.TimelineComment{{ID: 11, Type: "comment", Body: "first"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.TimelineComment + for event, err := range f.Issues.IterTimeline(context.Background(), "core", "go-forge", 1, nil, nil) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, event) + } + if !reflect.DeepEqual(seen, []types.TimelineComment{{ID: 11, Type: "comment", Body: "first"}}) { + t.Fatalf("got %#v", seen) + } +} + +func TestIssueService_ListSubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Issues.ListSubscriptions(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(users, []types.User{{ID: 1, UserName: "alice"}, {ID: 2, UserName: "bob"}}) { + t.Fatalf("got %#v", users) + } +} + +func TestIssueService_IterSubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.User{{ID: 1, UserName: "alice"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.User + for user, err := range f.Issues.IterSubscriptions(context.Background(), "core", "go-forge", 1) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, user) + } + if !reflect.DeepEqual(seen, []types.User{{ID: 1, UserName: "alice"}}) { + t.Fatalf("got %#v", seen) + } +} + +func TestIssueService_CheckSubscription_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/subscriptions/check" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.WatchInfo{Subscribed: true, Ignored: false}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Issues.CheckSubscription(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if !result.Subscribed || result.Ignored { + t.Fatalf("got %#v", result) + } +} + +func TestIssueService_SubscribeUser_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/subscriptions/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.SubscribeUser(context.Background(), "core", "go-forge", 1, "alice"); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_UnsubscribeUser_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/subscriptions/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.UnsubscribeUser(context.Background(), "core", "go-forge", 1, "alice"); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListDependencies_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/dependencies" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 11, Index: 11, Title: "blocking issue"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListDependencies(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(issues, []types.Issue{{ID: 11, Index: 11, Title: "blocking issue"}}) { + t.Fatalf("got %#v", issues) + } +} + +func TestIssueService_AddDependency_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/dependencies" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.IssueMeta + json.NewDecoder(r.Body).Decode(&body) + if body.Owner != "core" || body.Name != "go-forge" || body.Index != 2 { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Issue{ID: 11, Index: 11, Title: "blocking issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.AddDependency(context.Background(), "core", "go-forge", 1, types.IssueMeta{Owner: "core", Name: "go-forge", Index: 2}); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_RemoveDependency_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/dependencies" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.IssueMeta + json.NewDecoder(r.Body).Decode(&body) + if body.Owner != "core" || body.Name != "go-forge" || body.Index != 2 { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Issue{ID: 11, Index: 11, Title: "blocking issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.RemoveDependency(context.Background(), "core", "go-forge", 1, types.IssueMeta{Owner: "core", Name: "go-forge", Index: 2}); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListBlocks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/blocks" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 22, Index: 22, Title: "blocked issue"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListBlocks(context.Background(), "core", "go-forge", 1) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(issues, []types.Issue{{ID: 22, Index: 22, Title: "blocked issue"}}) { + t.Fatalf("got %#v", issues) + } +} + +func TestIssueService_AddBlock_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/blocks" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.IssueMeta + json.NewDecoder(r.Body).Decode(&body) + if body.Owner != "core" || body.Name != "go-forge" || body.Index != 3 { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Issue{ID: 22, Index: 22, Title: "blocked issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.AddBlock(context.Background(), "core", "go-forge", 1, types.IssueMeta{Owner: "core", Name: "go-forge", Index: 3}); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_RemoveBlock_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/1/blocks" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.IssueMeta + json.NewDecoder(r.Body).Decode(&body) + if body.Owner != "core" || body.Name != "go-forge" || body.Index != 3 { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Issue{ID: 22, Index: 22, Title: "blocked issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.RemoveBlock(context.Background(), "core", "go-forge", 1, types.IssueMeta{Owner: "core", Name: "go-forge", Index: 3}); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_Pin_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/pin" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Issues.Pin(context.Background(), "core", "go-forge", 42) + if err != nil { + t.Fatal(err) + } +} + +func TestIssueService_MovePin_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/pin/3" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.MovePin(context.Background(), "core", "go-forge", 42, 3); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListPinnedIssues_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/pinned" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "critical bug"}, + {ID: 2, Title: "release blocker"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListPinnedIssues(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if got, want := len(issues), 2; got != want { + t.Fatalf("got %d issues, want %d", got, want) + } + if issues[0].Title != "critical bug" { + t.Fatalf("got first title %q", issues[0].Title) + } +} + +func TestIssueService_IterPinnedIssues_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/pinned" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + switch requests { + case 1: + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{{ID: 1, Title: "critical bug"}}) + case 2: + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{{ID: 2, Title: "release blocker"}}) + default: + t.Fatalf("unexpected request %d", requests) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for issue, err := range f.Issues.IterPinnedIssues(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, issue.Title) + } + if len(got) != 2 || got[0] != "critical bug" || got[1] != "release blocker" { + t.Fatalf("got %#v", got) + } +} + +func TestIssueService_DeleteStopwatch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/stopwatch/delete" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.DeleteStopwatch(context.Background(), "core", "go-forge", 42); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_ListTimes_Good(t *testing.T) { + since := time.Date(2026, time.March, 3, 9, 15, 0, 0, time.UTC) + before := time.Date(2026, time.March, 4, 9, 15, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/times" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("user"); got != "alice" { + t.Errorf("got user=%q, want %q", got, "alice") + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.TrackedTime{ + {ID: 11, Time: 30, UserName: "alice"}, + {ID: 12, Time: 90, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + times, err := f.Issues.ListTimes(context.Background(), "core", "go-forge", 42, "alice", &since, &before) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(times, []types.TrackedTime{ + {ID: 11, Time: 30, UserName: "alice"}, + {ID: 12, Time: 90, UserName: "bob"}, + }) { + t.Fatalf("got %#v", times) + } +} + +func TestIssueService_IterTimes_Good(t *testing.T) { + since := time.Date(2026, time.March, 3, 9, 15, 0, 0, time.UTC) + before := time.Date(2026, time.March, 4, 9, 15, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/times" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("user"); got != "alice" { + t.Errorf("got user=%q, want %q", got, "alice") + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("got limit=%q, want %q", got, "50") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.TrackedTime{ + {ID: 11, Time: 30, UserName: "alice"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var seen []types.TrackedTime + for entry, err := range f.Issues.IterTimes(context.Background(), "core", "go-forge", 42, "alice", &since, &before) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, entry) + } + if !reflect.DeepEqual(seen, []types.TrackedTime{ + {ID: 11, Time: 30, UserName: "alice"}, + }) { + t.Fatalf("got %#v", seen) + } +} + +func TestIssueService_AddTime_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/times" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.AddTimeOption + json.NewDecoder(r.Body).Decode(&body) + if body.Time != 180 || body.User != "alice" { + t.Fatalf("got body=%#v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.TrackedTime{ID: 99, Time: body.Time, UserName: body.User}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + got, err := f.Issues.AddTime(context.Background(), "core", "go-forge", 42, &types.AddTimeOption{ + Time: 180, + User: "alice", + }) + if err != nil { + t.Fatal(err) + } + if got.ID != 99 || got.Time != 180 || got.UserName != "alice" { + t.Fatalf("got %#v", got) + } +} + +func TestIssueService_ResetTime_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/times" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.ResetTime(context.Background(), "core", "go-forge", 42); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_DeleteTime_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/times/99" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Issues.DeleteTime(context.Background(), "core", "go-forge", 42, 99); err != nil { + t.Fatal(err) + } +} + +func TestIssueService_List_Bad(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"message": "boom"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Issues.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, DefaultList); err == nil { + t.Fatal("expected error") + } +} + +func TestIssueService_ListIgnoresIndexParam_Ugly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "0") + json.NewEncoder(w).Encode([]types.Issue{}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Issues.List(context.Background(), Params{"owner": "core", "repo": "go-forge", "index": "99"}, DefaultList) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 0 { + t.Errorf("got %d items, want 0", len(result.Items)) + } } diff --git a/labels.go b/labels.go index acb1146..37a12bb 100644 --- a/labels.go +++ b/labels.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -10,6 +9,11 @@ import ( // LabelService handles repository labels, organisation labels, and issue labels. // No Resource embedding — paths are heterogeneous. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Labels.ListRepoLabels(ctx, "core", "go-forge") type LabelService struct { client *Client } @@ -20,19 +24,19 @@ func newLabelService(c *Client) *LabelService { // ListRepoLabels returns all labels for a repository. func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListAll[types.Label](ctx, s.client, path, nil) } // IterRepoLabels returns an iterator over all labels for a repository. func (s *LabelService) IterRepoLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListIter[types.Label](ctx, s.client, path, nil) } // GetRepoLabel returns a single label by ID. func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) var out types.Label if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,7 +46,7 @@ func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id // CreateRepoLabel creates a new label in a repository. func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, opts *types.CreateLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -52,7 +56,7 @@ func (s *LabelService) CreateRepoLabel(ctx context.Context, owner, repo string, // EditRepoLabel updates an existing label in a repository. func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id int64, opts *types.EditLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) var out types.Label if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -62,28 +66,89 @@ func (s *LabelService) EditRepoLabel(ctx context.Context, owner, repo string, id // DeleteRepoLabel deletes a label from a repository. func (s *LabelService) DeleteRepoLabel(ctx context.Context, owner, repo string, id int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) return s.client.Delete(ctx, path) } // ListOrgLabels returns all labels for an organisation. func (s *LabelService) ListOrgLabels(ctx context.Context, org string) ([]types.Label, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := ResolvePath("/api/v1/orgs/{org}/labels", pathParams("org", org)) return ListAll[types.Label](ctx, s.client, path, nil) } // IterOrgLabels returns an iterator over all labels for an organisation. func (s *LabelService) IterOrgLabels(ctx context.Context, org string) iter.Seq2[types.Label, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := ResolvePath("/api/v1/orgs/{org}/labels", pathParams("org", org)) return ListIter[types.Label](ctx, s.client, path, nil) } // CreateOrgLabel creates a new label in an organisation. func (s *LabelService) CreateOrgLabel(ctx context.Context, org string, opts *types.CreateLabelOption) (*types.Label, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/labels", org) + path := ResolvePath("/api/v1/orgs/{org}/labels", pathParams("org", org)) var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err } return &out, nil } + +// GetOrgLabel returns a single label for an organisation. +func (s *LabelService) GetOrgLabel(ctx context.Context, org string, id int64) (*types.Label, error) { + path := ResolvePath("/api/v1/orgs/{org}/labels/{id}", pathParams("org", org, "id", int64String(id))) + var out types.Label + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditOrgLabel updates an existing label in an organisation. +func (s *LabelService) EditOrgLabel(ctx context.Context, org string, id int64, opts *types.EditLabelOption) (*types.Label, error) { + path := ResolvePath("/api/v1/orgs/{org}/labels/{id}", pathParams("org", org, "id", int64String(id))) + var out types.Label + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteOrgLabel deletes a label from an organisation. +func (s *LabelService) DeleteOrgLabel(ctx context.Context, org string, id int64) error { + path := ResolvePath("/api/v1/orgs/{org}/labels/{id}", pathParams("org", org, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListLabelTemplates returns all available label template names. +func (s *LabelService) ListLabelTemplates(ctx context.Context) ([]string, error) { + var out []string + if err := s.client.Get(ctx, "/api/v1/label/templates", &out); err != nil { + return nil, err + } + return out, nil +} + +// IterLabelTemplates returns an iterator over all available label template names. +func (s *LabelService) IterLabelTemplates(ctx context.Context) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + items, err := s.ListLabelTemplates(ctx) + if err != nil { + yield("", err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + +// GetLabelTemplate returns all labels for a label template. +func (s *LabelService) GetLabelTemplate(ctx context.Context, name string) ([]types.LabelTemplate, error) { + path := ResolvePath("/api/v1/label/templates/{name}", pathParams("name", name)) + var out []types.LabelTemplate + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/labels_test.go b/labels_test.go index c4af73d..92f8b87 100644 --- a/labels_test.go +++ b/labels_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestLabelService_Good_ListRepoLabels(t *testing.T) { +func TestLabelService_ListRepoLabels_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -42,7 +42,7 @@ func TestLabelService_Good_ListRepoLabels(t *testing.T) { } } -func TestLabelService_Good_CreateRepoLabel(t *testing.T) { +func TestLabelService_CreateRepoLabel_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -84,7 +84,7 @@ func TestLabelService_Good_CreateRepoLabel(t *testing.T) { } } -func TestLabelService_Good_GetRepoLabel(t *testing.T) { +func TestLabelService_GetRepoLabel_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -106,7 +106,7 @@ func TestLabelService_Good_GetRepoLabel(t *testing.T) { } } -func TestLabelService_Good_EditRepoLabel(t *testing.T) { +func TestLabelService_EditRepoLabel_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) @@ -135,7 +135,7 @@ func TestLabelService_Good_EditRepoLabel(t *testing.T) { } } -func TestLabelService_Good_DeleteRepoLabel(t *testing.T) { +func TestLabelService_DeleteRepoLabel_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -154,7 +154,7 @@ func TestLabelService_Good_DeleteRepoLabel(t *testing.T) { } } -func TestLabelService_Good_ListOrgLabels(t *testing.T) { +func TestLabelService_ListOrgLabels_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -182,7 +182,7 @@ func TestLabelService_Good_ListOrgLabels(t *testing.T) { } } -func TestLabelService_Good_CreateOrgLabel(t *testing.T) { +func TestLabelService_CreateOrgLabel_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -214,7 +214,7 @@ func TestLabelService_Good_CreateOrgLabel(t *testing.T) { } } -func TestLabelService_Bad_NotFound(t *testing.T) { +func TestLabelService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "label not found"}) diff --git a/milestones.go b/milestones.go new file mode 100644 index 0000000..bdf1050 --- /dev/null +++ b/milestones.go @@ -0,0 +1,125 @@ +package forge + +import ( + "context" + "iter" + + "dappco.re/go/core/forge/types" +) + +// MilestoneListOptions controls filtering for repository milestone listings. +// +// Usage: +// +// opts := forge.MilestoneListOptions{State: "open"} +type MilestoneListOptions struct { + State string + Name string +} + +// String returns a safe summary of the milestone filters. +func (o MilestoneListOptions) String() string { + return optionString("forge.MilestoneListOptions", "state", o.State, "name", o.Name) +} + +// GoString returns a safe Go-syntax summary of the milestone filters. +func (o MilestoneListOptions) GoString() string { return o.String() } + +func (o MilestoneListOptions) queryParams() map[string]string { + query := make(map[string]string, 2) + if o.State != "" { + query["state"] = o.State + } + if o.Name != "" { + query["name"] = o.Name + } + if len(query) == 0 { + return nil + } + return query +} + +// MilestoneService handles repository milestones. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Milestones.ListAll(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) +type MilestoneService struct { + client *Client +} + +func newMilestoneService(c *Client) *MilestoneService { + return &MilestoneService{client: c} +} + +// List returns a single page of milestones for a repository. +func (s *MilestoneService) List(ctx context.Context, params Params, opts ListOptions, filters ...MilestoneListOptions) (*PagedResult[types.Milestone], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) + return ListPage[types.Milestone](ctx, s.client, path, milestoneQuery(filters...), opts) +} + +// Iter returns an iterator over all milestones for a repository. +func (s *MilestoneService) Iter(ctx context.Context, params Params, filters ...MilestoneListOptions) iter.Seq2[types.Milestone, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) + return ListIter[types.Milestone](ctx, s.client, path, milestoneQuery(filters...)) +} + +// ListAll returns all milestones for a repository. +func (s *MilestoneService) ListAll(ctx context.Context, params Params, filters ...MilestoneListOptions) ([]types.Milestone, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) + return ListAll[types.Milestone](ctx, s.client, path, milestoneQuery(filters...)) +} + +// Get returns a single milestone by ID. +func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Milestone + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Create creates a new milestone. +func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", pathParams("owner", owner, "repo", repo)) + var out types.Milestone + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Edit updates an existing milestone. +func (s *MilestoneService) Edit(ctx context.Context, owner, repo string, id int64, opts *types.EditMilestoneOption) (*types.Milestone, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.Milestone + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a milestone. +func (s *MilestoneService) Delete(ctx context.Context, owner, repo string, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +func milestoneQuery(filters ...MilestoneListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 2) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/milestones_test.go b/milestones_test.go new file mode 100644 index 0000000..8def945 --- /dev/null +++ b/milestones_test.go @@ -0,0 +1,273 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestMilestoneService_List_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Errorf("got limit=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 2, Title: "v2.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Milestones.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, ListOptions{Page: 1, Limit: 1}) + if err != nil { + t.Fatal(err) + } + if page.Page != 1 { + t.Errorf("got page=%d, want 1", page.Page) + } + if page.TotalCount != 2 { + t.Errorf("got total=%d, want 2", page.TotalCount) + } + if !page.HasMore { + t.Error("expected HasMore=true") + } + if len(page.Items) != 1 || page.Items[0].Title != "v2.0" { + t.Fatalf("unexpected items: %+v", page.Items) + } +} + +func TestMilestoneService_ListWithFilters_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "all" { + t.Errorf("got state=%q, want %q", got, "all") + } + if got := r.URL.Query().Get("name"); got != "v1.0" { + t.Errorf("got name=%q, want %q", got, "v1.0") + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Errorf("got limit=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 1, Title: "v1.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Milestones.List( + context.Background(), + Params{"owner": "core", "repo": "go-forge"}, + ListOptions{Page: 1, Limit: 1}, + MilestoneListOptions{State: "all", Name: "v1.0"}, + ) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Items[0].Title != "v1.0" { + t.Fatalf("unexpected items: %+v", page.Items) + } +} + +func TestMilestoneService_Iter_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + switch requests { + case 1: + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 1, Title: "v1.0"}}) + case 2: + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 2, Title: "v2.0"}}) + default: + t.Fatalf("unexpected request %d", requests) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for milestone, err := range f.Milestones.Iter(context.Background(), Params{"owner": "core", "repo": "go-forge"}) { + if err != nil { + t.Fatal(err) + } + got = append(got, milestone.Title) + } + if !reflect.DeepEqual(got, []string{"v1.0", "v2.0"}) { + t.Fatalf("got %v", got) + } +} + +func TestMilestoneService_ListAll_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.Milestone{ + {ID: 1, Title: "v1.0"}, + {ID: 2, Title: "v2.0"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + milestones, err := f.Milestones.ListAll(context.Background(), Params{"owner": "core", "repo": "go-forge"}) + if err != nil { + t.Fatal(err) + } + if len(milestones) != 2 { + t.Errorf("got %d milestones, want 2", len(milestones)) + } + if milestones[0].Title != "v1.0" { + t.Errorf("got title=%q, want %q", milestones[0].Title, "v1.0") + } +} + +func TestMilestoneService_Get_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Milestone{ID: 7, Title: "v1.0"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + milestone, err := f.Milestones.Get(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if milestone.ID != 7 { + t.Errorf("got id=%d, want 7", milestone.ID) + } + if milestone.Title != "v1.0" { + t.Errorf("got title=%q, want %q", milestone.Title, "v1.0") + } +} + +func TestMilestoneService_Create_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateMilestoneOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Title != "v1.0" { + t.Errorf("got title=%q, want %q", opts.Title, "v1.0") + } + + json.NewEncoder(w).Encode(types.Milestone{ID: 3, Title: opts.Title}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + milestone, err := f.Milestones.Create(context.Background(), "core", "go-forge", &types.CreateMilestoneOption{ + Title: "v1.0", + }) + if err != nil { + t.Fatal(err) + } + if milestone.ID != 3 { + t.Errorf("got id=%d, want 3", milestone.ID) + } + if milestone.Title != "v1.0" { + t.Errorf("got title=%q, want %q", milestone.Title, "v1.0") + } +} + +func TestMilestoneService_Edit_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones/3" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditMilestoneOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Title != "v1.1" { + t.Errorf("got title=%q, want %q", opts.Title, "v1.1") + } + + json.NewEncoder(w).Encode(types.Milestone{ID: 3, Title: opts.Title}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + milestone, err := f.Milestones.Edit(context.Background(), "core", "go-forge", 3, &types.EditMilestoneOption{ + Title: "v1.1", + }) + if err != nil { + t.Fatal(err) + } + if milestone.ID != 3 { + t.Errorf("got id=%d, want 3", milestone.ID) + } + if milestone.Title != "v1.1" { + t.Errorf("got title=%q, want %q", milestone.Title, "v1.1") + } +} + +func TestMilestoneService_Delete_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones/3" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Milestones.Delete(context.Background(), "core", "go-forge", 3); err != nil { + t.Fatal(err) + } +} diff --git a/misc.go b/misc.go index b05526a..d53d15f 100644 --- a/misc.go +++ b/misc.go @@ -2,7 +2,7 @@ package forge import ( "context" - "fmt" + "iter" "dappco.re/go/core/forge/types" ) @@ -11,6 +11,11 @@ import ( // markdown rendering, licence templates, gitignore templates, and // server metadata. // No Resource embedding — heterogeneous read-only endpoints. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Misc.GetVersion(ctx) type MiscService struct { client *Client } @@ -30,6 +35,27 @@ func (s *MiscService) RenderMarkdown(ctx context.Context, text, mode string) (st return string(data), nil } +// RenderMarkup renders markup text to HTML. The response is raw HTML text, +// not JSON. +func (s *MiscService) RenderMarkup(ctx context.Context, text, mode string) (string, error) { + body := types.MarkupOption{Text: text, Mode: mode} + data, err := s.client.PostRaw(ctx, "/api/v1/markup", body) + if err != nil { + return "", err + } + return string(data), nil +} + +// RenderMarkdownRaw renders raw markdown text to HTML. The request body is +// sent as text/plain and the response is raw HTML text, not JSON. +func (s *MiscService) RenderMarkdownRaw(ctx context.Context, text string) (string, error) { + data, err := s.client.postRawText(ctx, "/api/v1/markdown/raw", text) + if err != nil { + return "", err + } + return string(data), nil +} + // ListLicenses returns all available licence templates. func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplateListEntry, error) { var out []types.LicensesTemplateListEntry @@ -39,9 +65,25 @@ func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplat return out, nil } +// IterLicenses returns an iterator over all available licence templates. +func (s *MiscService) IterLicenses(ctx context.Context) iter.Seq2[types.LicensesTemplateListEntry, error] { + return func(yield func(types.LicensesTemplateListEntry, error) bool) { + items, err := s.ListLicenses(ctx) + if err != nil { + yield(*new(types.LicensesTemplateListEntry), err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + // GetLicense returns a single licence template by name. func (s *MiscService) GetLicense(ctx context.Context, name string) (*types.LicenseTemplateInfo, error) { - path := fmt.Sprintf("/api/v1/licenses/%s", name) + path := ResolvePath("/api/v1/licenses/{name}", pathParams("name", name)) var out types.LicenseTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -58,9 +100,25 @@ func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, err return out, nil } +// IterGitignoreTemplates returns an iterator over all available gitignore template names. +func (s *MiscService) IterGitignoreTemplates(ctx context.Context) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + items, err := s.ListGitignoreTemplates(ctx) + if err != nil { + yield("", err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + // GetGitignoreTemplate returns a single gitignore template by name. func (s *MiscService) GetGitignoreTemplate(ctx context.Context, name string) (*types.GitignoreTemplateInfo, error) { - path := fmt.Sprintf("/api/v1/gitignore/templates/%s", name) + path := ResolvePath("/api/v1/gitignore/templates/{name}", pathParams("name", name)) var out types.GitignoreTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -77,6 +135,51 @@ func (s *MiscService) GetNodeInfo(ctx context.Context) (*types.NodeInfo, error) return &out, nil } +// GetSigningKey returns the instance's default signing key. +func (s *MiscService) GetSigningKey(ctx context.Context) (string, error) { + data, err := s.client.GetRaw(ctx, "/api/v1/signing-key.gpg") + if err != nil { + return "", err + } + return string(data), nil +} + +// GetAPISettings returns the instance's global API settings. +func (s *MiscService) GetAPISettings(ctx context.Context) (*types.GeneralAPISettings, error) { + var out types.GeneralAPISettings + if err := s.client.Get(ctx, "/api/v1/settings/api", &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetAttachmentSettings returns the instance's global attachment settings. +func (s *MiscService) GetAttachmentSettings(ctx context.Context) (*types.GeneralAttachmentSettings, error) { + var out types.GeneralAttachmentSettings + if err := s.client.Get(ctx, "/api/v1/settings/attachment", &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetRepositorySettings returns the instance's global repository settings. +func (s *MiscService) GetRepositorySettings(ctx context.Context) (*types.GeneralRepoSettings, error) { + var out types.GeneralRepoSettings + if err := s.client.Get(ctx, "/api/v1/settings/repository", &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetUISettings returns the instance's global UI settings. +func (s *MiscService) GetUISettings(ctx context.Context) (*types.GeneralUISettings, error) { + var out types.GeneralUISettings + if err := s.client.Get(ctx, "/api/v1/settings/ui", &out); err != nil { + return nil, err + } + return &out, nil +} + // GetVersion returns the server version. func (s *MiscService) GetVersion(ctx context.Context) (*types.ServerVersion, error) { var out types.ServerVersion diff --git a/misc_test.go b/misc_test.go index 25cf7f4..9685084 100644 --- a/misc_test.go +++ b/misc_test.go @@ -2,15 +2,17 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "dappco.re/go/core/forge/types" ) -func TestMiscService_Good_RenderMarkdown(t *testing.T) { +func TestMiscService_RenderMarkdown_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -44,7 +46,85 @@ func TestMiscService_Good_RenderMarkdown(t *testing.T) { } } -func TestMiscService_Good_GetVersion(t *testing.T) { +func TestMiscService_RenderMarkup_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/markup" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.MarkupOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Text != "**Hello**" { + t.Errorf("got text=%q, want %q", opts.Text, "**Hello**") + } + if opts.Mode != "gfm" { + t.Errorf("got mode=%q, want %q", opts.Mode, "gfm") + } + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("

Hello

\n")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + html, err := f.Misc.RenderMarkup(context.Background(), "**Hello**", "gfm") + if err != nil { + t.Fatal(err) + } + want := "

Hello

\n" + if html != want { + t.Errorf("got %q, want %q", html, want) + } +} + +func TestMiscService_RenderMarkdownRaw_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/markdown/raw" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") { + t.Errorf("got content-type=%q, want text/plain", got) + } + if got := r.Header.Get("Accept"); got != "text/html" { + t.Errorf("got accept=%q, want text/html", got) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if string(data) != "# Hello" { + t.Errorf("got body=%q, want %q", string(data), "# Hello") + } + w.Header().Set("X-RateLimit-Limit", "80") + w.Header().Set("X-RateLimit-Remaining", "79") + w.Header().Set("X-RateLimit-Reset", "1700000003") + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("

Hello

\n")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + html, err := f.Misc.RenderMarkdownRaw(context.Background(), "# Hello") + if err != nil { + t.Fatal(err) + } + want := "

Hello

\n" + if html != want { + t.Errorf("got %q, want %q", html, want) + } + rl := f.Client().RateLimit() + if rl.Limit != 80 || rl.Remaining != 79 || rl.Reset != 1700000003 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + +func TestMiscService_GetVersion_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -68,7 +148,117 @@ func TestMiscService_Good_GetVersion(t *testing.T) { } } -func TestMiscService_Good_ListLicenses(t *testing.T) { +func TestMiscService_GetAPISettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/settings/api" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GeneralAPISettings{ + DefaultGitTreesPerPage: 25, + DefaultMaxBlobSize: 4096, + DefaultPagingNum: 1, + MaxResponseItems: 500, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Misc.GetAPISettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.DefaultPagingNum != 1 || settings.MaxResponseItems != 500 { + t.Fatalf("unexpected api settings: %+v", settings) + } +} + +func TestMiscService_GetAttachmentSettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/settings/attachment" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GeneralAttachmentSettings{ + AllowedTypes: "image/*", + Enabled: true, + MaxFiles: 10, + MaxSize: 1048576, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Misc.GetAttachmentSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if !settings.Enabled || settings.MaxFiles != 10 { + t.Fatalf("unexpected attachment settings: %+v", settings) + } +} + +func TestMiscService_GetRepositorySettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/settings/repository" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GeneralRepoSettings{ + ForksDisabled: true, + HTTPGitDisabled: true, + LFSDisabled: true, + MigrationsDisabled: true, + MirrorsDisabled: false, + StarsDisabled: true, + TimeTrackingDisabled: false, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Misc.GetRepositorySettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if !settings.ForksDisabled || !settings.HTTPGitDisabled { + t.Fatalf("unexpected repository settings: %+v", settings) + } +} + +func TestMiscService_GetUISettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/settings/ui" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GeneralUISettings{ + AllowedReactions: []string{"+1", "-1"}, + CustomEmojis: []string{":forgejo:"}, + DefaultTheme: "forgejo-auto", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Misc.GetUISettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.DefaultTheme != "forgejo-auto" || len(settings.AllowedReactions) != 2 { + t.Fatalf("unexpected ui settings: %+v", settings) + } +} + +func TestMiscService_ListLicenses_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -99,7 +289,38 @@ func TestMiscService_Good_ListLicenses(t *testing.T) { } } -func TestMiscService_Good_GetLicense(t *testing.T) { +func TestMiscService_IterLicenses_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/licenses" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.LicensesTemplateListEntry{ + {Key: "mit", Name: "MIT License"}, + {Key: "gpl-3.0", Name: "GNU General Public License v3.0"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var names []string + for item, err := range f.Misc.IterLicenses(context.Background()) { + if err != nil { + t.Fatal(err) + } + names = append(names, item.Name) + } + if len(names) != 2 { + t.Fatalf("got %d licences, want 2", len(names)) + } + if names[0] != "MIT License" || names[1] != "GNU General Public License v3.0" { + t.Fatalf("unexpected licences: %+v", names) + } +} + +func TestMiscService_GetLicense_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -128,7 +349,7 @@ func TestMiscService_Good_GetLicense(t *testing.T) { } } -func TestMiscService_Good_ListGitignoreTemplates(t *testing.T) { +func TestMiscService_ListGitignoreTemplates_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -153,7 +374,35 @@ func TestMiscService_Good_ListGitignoreTemplates(t *testing.T) { } } -func TestMiscService_Good_GetGitignoreTemplate(t *testing.T) { +func TestMiscService_IterGitignoreTemplates_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/gitignore/templates" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]string{"Go", "Python", "Node"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var names []string + for item, err := range f.Misc.IterGitignoreTemplates(context.Background()) { + if err != nil { + t.Fatal(err) + } + names = append(names, item) + } + if len(names) != 3 { + t.Fatalf("got %d templates, want 3", len(names)) + } + if names[0] != "Go" { + t.Errorf("got [0]=%q, want %q", names[0], "Go") + } +} + +func TestMiscService_GetGitignoreTemplate_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -178,7 +427,7 @@ func TestMiscService_Good_GetGitignoreTemplate(t *testing.T) { } } -func TestMiscService_Good_GetNodeInfo(t *testing.T) { +func TestMiscService_GetNodeInfo_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -209,7 +458,30 @@ func TestMiscService_Good_GetNodeInfo(t *testing.T) { } } -func TestMiscService_Bad_NotFound(t *testing.T) { +func TestMiscService_GetSigningKey_Good(t *testing.T) { + want := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n..." + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/signing-key.gpg" { + t.Errorf("wrong path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(want)) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Misc.GetSigningKey(context.Background()) + if err != nil { + t.Fatal(err) + } + if key != want { + t.Fatalf("got %q, want %q", key, want) + } +} + +func TestMiscService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) diff --git a/notifications.go b/notifications.go index e3b8af2..cad311d 100644 --- a/notifications.go +++ b/notifications.go @@ -2,42 +2,216 @@ package forge import ( "context" - "fmt" "iter" + "net/http" + "net/url" + "strconv" + "time" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) +// NotificationListOptions controls filtering for notification listings. +// +// Usage: +// +// opts := forge.NotificationListOptions{All: true, StatusTypes: []string{"unread"}} +type NotificationListOptions struct { + All bool + StatusTypes []string + SubjectTypes []string + Since *time.Time + Before *time.Time +} + +// String returns a safe summary of the notification filters. +func (o NotificationListOptions) String() string { + return optionString("forge.NotificationListOptions", + "all", o.All, + "status_types", o.StatusTypes, + "subject_types", o.SubjectTypes, + "since", o.Since, + "before", o.Before, + ) +} + +// GoString returns a safe Go-syntax summary of the notification filters. +func (o NotificationListOptions) GoString() string { return o.String() } + +func (o NotificationListOptions) addQuery(values url.Values) { + if o.All { + values.Set("all", "true") + } + for _, status := range o.StatusTypes { + if status != "" { + values.Add("status-types", status) + } + } + for _, subjectType := range o.SubjectTypes { + if subjectType != "" { + values.Add("subject-type", subjectType) + } + } + if o.Since != nil { + values.Set("since", o.Since.Format(time.RFC3339)) + } + if o.Before != nil { + values.Set("before", o.Before.Format(time.RFC3339)) + } +} + // NotificationService handles notification operations via the Forgejo API. // No Resource embedding — varied endpoint shapes. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Notifications.List(ctx) type NotificationService struct { client *Client } +// NotificationRepoMarkOptions controls how repository notifications are marked. +// +// Usage: +// +// opts := forge.NotificationRepoMarkOptions{All: true, ToStatus: "read"} +type NotificationRepoMarkOptions struct { + All bool + StatusTypes []string + ToStatus string + LastReadAt *time.Time +} + +// String returns a safe summary of the repository notification mark options. +func (o NotificationRepoMarkOptions) String() string { + return optionString("forge.NotificationRepoMarkOptions", + "all", o.All, + "status_types", o.StatusTypes, + "to_status", o.ToStatus, + "last_read_at", o.LastReadAt, + ) +} + +// GoString returns a safe Go-syntax summary of the repository notification mark options. +func (o NotificationRepoMarkOptions) GoString() string { return o.String() } + +// NotificationMarkOptions controls how authenticated-user notifications are marked. +// +// Usage: +// +// opts := forge.NotificationMarkOptions{All: true, ToStatus: "read"} +type NotificationMarkOptions struct { + All bool + StatusTypes []string + ToStatus string + LastReadAt *time.Time +} + +// String returns a safe summary of the authenticated-user notification mark options. +func (o NotificationMarkOptions) String() string { + return optionString("forge.NotificationMarkOptions", + "all", o.All, + "status_types", o.StatusTypes, + "to_status", o.ToStatus, + "last_read_at", o.LastReadAt, + ) +} + +// GoString returns a safe Go-syntax summary of the authenticated-user notification mark options. +func (o NotificationMarkOptions) GoString() string { return o.String() } + func newNotificationService(c *Client) *NotificationService { return &NotificationService{client: c} } +func notificationMarkQueryString(all bool, statusTypes []string, toStatus string, lastReadAt *time.Time) string { + values := url.Values{} + if all { + values.Set("all", "true") + } + for _, status := range statusTypes { + if status != "" { + values.Add("status-types", status) + } + } + if toStatus != "" { + values.Set("to-status", toStatus) + } + if lastReadAt != nil { + values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) + } + return values.Encode() +} + +func (o NotificationRepoMarkOptions) queryString() string { + return notificationMarkQueryString(o.All, o.StatusTypes, o.ToStatus, o.LastReadAt) +} + +func (o NotificationMarkOptions) queryString() string { + return notificationMarkQueryString(o.All, o.StatusTypes, o.ToStatus, o.LastReadAt) +} + // List returns all notifications for the authenticated user. -func (s *NotificationService) List(ctx context.Context) ([]types.NotificationThread, error) { - return ListAll[types.NotificationThread](ctx, s.client, "/api/v1/notifications", nil) +func (s *NotificationService) List(ctx context.Context, filters ...NotificationListOptions) ([]types.NotificationThread, error) { + return s.listAll(ctx, "/api/v1/notifications", filters...) } // Iter returns an iterator over all notifications for the authenticated user. -func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.NotificationThread, error] { - return ListIter[types.NotificationThread](ctx, s.client, "/api/v1/notifications", nil) +func (s *NotificationService) Iter(ctx context.Context, filters ...NotificationListOptions) iter.Seq2[types.NotificationThread, error] { + return s.listIter(ctx, "/api/v1/notifications", filters...) +} + +// NewAvailable returns the count of unread notifications for the authenticated user. +func (s *NotificationService) NewAvailable(ctx context.Context) (*types.NotificationCount, error) { + var out types.NotificationCount + if err := s.client.Get(ctx, "/api/v1/notifications/new", &out); err != nil { + return nil, err + } + return &out, nil } // ListRepo returns all notifications for a specific repository. -func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) - return ListAll[types.NotificationThread](ctx, s.client, path, nil) +func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string, filters ...NotificationListOptions) ([]types.NotificationThread, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) + return s.listAll(ctx, path, filters...) } // IterRepo returns an iterator over all notifications for a specific repository. -func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string) iter.Seq2[types.NotificationThread, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) - return ListIter[types.NotificationThread](ctx, s.client, path, nil) +func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string, filters ...NotificationListOptions) iter.Seq2[types.NotificationThread, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) + return s.listIter(ctx, path, filters...) +} + +// MarkNotifications marks authenticated-user notification threads as read, pinned, or unread. +func (s *NotificationService) MarkNotifications(ctx context.Context, opts *NotificationMarkOptions) ([]types.NotificationThread, error) { + path := "/api/v1/notifications" + if opts != nil { + if query := opts.queryString(); query != "" { + path += "?" + query + } + } + var out []types.NotificationThread + if err := s.client.Put(ctx, path, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// MarkRepoNotifications marks repository notification threads as read, unread, or pinned. +func (s *NotificationService) MarkRepoNotifications(ctx context.Context, owner, repo string, opts *NotificationRepoMarkOptions) ([]types.NotificationThread, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) + if opts != nil { + if query := opts.queryString(); query != "" { + path += "?" + query + } + } + var out []types.NotificationThread + if err := s.client.Put(ctx, path, nil, &out); err != nil { + return nil, err + } + return out, nil } // MarkRead marks all notifications as read. @@ -47,7 +221,7 @@ func (s *NotificationService) MarkRead(ctx context.Context) error { // GetThread returns a single notification thread by ID. func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.NotificationThread, error) { - path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + path := ResolvePath("/api/v1/notifications/threads/{id}", pathParams("id", int64String(id))) var out types.NotificationThread if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -57,6 +231,84 @@ func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.N // MarkThreadRead marks a single notification thread as read. func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) error { - path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + path := ResolvePath("/api/v1/notifications/threads/{id}", pathParams("id", int64String(id))) return s.client.Patch(ctx, path, nil, nil) } + +func (s *NotificationService) listAll(ctx context.Context, path string, filters ...NotificationListOptions) ([]types.NotificationThread, error) { + var all []types.NotificationThread + page := 1 + + for { + result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +func (s *NotificationService) listIter(ctx context.Context, path string, filters ...NotificationListOptions) iter.Seq2[types.NotificationThread, error] { + return func(yield func(types.NotificationThread, error) bool) { + page := 1 + for { + result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + if err != nil { + yield(*new(types.NotificationThread), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + +func (s *NotificationService) listPage(ctx context.Context, path string, opts ListOptions, filters ...NotificationListOptions) (*PagedResult[types.NotificationThread], error) { + if opts.Page < 1 { + opts.Page = 1 + } + if opts.Limit < 1 { + opts.Limit = defaultPageLimit + } + + u, err := url.Parse(path) + if err != nil { + return nil, core.E("NotificationService.listPage", "forge: parse path", err) + } + + values := u.Query() + values.Set("page", strconv.Itoa(opts.Page)) + values.Set("limit", strconv.Itoa(opts.Limit)) + for _, filter := range filters { + filter.addQuery(values) + } + u.RawQuery = values.Encode() + + var items []types.NotificationThread + resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &items) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + return &PagedResult[types.NotificationThread]{ + Items: items, + TotalCount: totalCount, + Page: opts.Page, + HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= opts.Limit), + }, nil +} diff --git a/notifications_test.go b/notifications_test.go index a497451..45635bd 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -2,15 +2,16 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" + "time" "dappco.re/go/core/forge/types" ) -func TestNotificationService_Good_List(t *testing.T) { +func TestNotificationService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -45,7 +46,55 @@ func TestNotificationService_Good_List(t *testing.T) { } } -func TestNotificationService_Good_ListRepo(t *testing.T) { +func TestNotificationService_List_Filters(t *testing.T) { + since := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + before := time.Date(2026, time.April, 2, 12, 0, 0, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("all"); got != "true" { + t.Errorf("got all=%q, want true", got) + } + if got := r.URL.Query()["status-types"]; len(got) != 2 || got[0] != "unread" || got[1] != "pinned" { + t.Errorf("got status-types=%v, want [unread pinned]", got) + } + if got := r.URL.Query()["subject-type"]; len(got) != 2 || got[0] != "issue" || got[1] != "pull" { + t.Errorf("got subject-type=%v, want [issue pull]", got) + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + if got := r.URL.Query().Get("before"); got != before.Format(time.RFC3339) { + t.Errorf("got before=%q, want %q", got, before.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 11, Unread: true, Subject: &types.NotificationSubject{Title: "Filtered"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.List(context.Background(), NotificationListOptions{ + All: true, + StatusTypes: []string{"unread", "pinned"}, + SubjectTypes: []string{"issue", "pull"}, + Since: &since, + Before: &before, + }) + if err != nil { + t.Fatal(err) + } + if len(threads) != 1 || threads[0].ID != 11 { + t.Fatalf("got threads=%+v", threads) + } +} + +func TestNotificationService_ListRepo_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -73,7 +122,68 @@ func TestNotificationService_Good_ListRepo(t *testing.T) { } } -func TestNotificationService_Good_GetThread(t *testing.T) { +func TestNotificationService_ListRepo_Filters(t *testing.T) { + since := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query()["status-types"]; len(got) != 1 || got[0] != "read" { + t.Errorf("got status-types=%v, want [read]", got) + } + if got := r.URL.Query()["subject-type"]; len(got) != 1 || got[0] != "repository" { + t.Errorf("got subject-type=%v, want [repository]", got) + } + if got := r.URL.Query().Get("since"); got != since.Format(time.RFC3339) { + t.Errorf("got since=%q, want %q", got, since.Format(time.RFC3339)) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 12, Unread: false, Subject: &types.NotificationSubject{Title: "Repo filtered"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.ListRepo(context.Background(), "core", "go-forge", NotificationListOptions{ + StatusTypes: []string{"read"}, + SubjectTypes: []string{"repository"}, + Since: &since, + }) + if err != nil { + t.Fatal(err) + } + if len(threads) != 1 || threads[0].ID != 12 { + t.Fatalf("got threads=%+v", threads) + } +} + +func TestNotificationService_NewAvailable_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications/new" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.NotificationCount{New: 3}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count, err := f.Notifications.NewAvailable(context.Background()) + if err != nil { + t.Fatal(err) + } + if count.New != 3 { + t.Fatalf("got new=%d, want 3", count.New) + } +} + +func TestNotificationService_GetThread_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -107,7 +217,57 @@ func TestNotificationService_Good_GetThread(t *testing.T) { } } -func TestNotificationService_Good_MarkRead(t *testing.T) { +func TestNotificationService_MarkNotifications_Good(t *testing.T) { + lastReadAt := time.Date(2026, time.April, 2, 15, 4, 5, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("all"); got != "true" { + t.Errorf("got all=%q, want true", got) + } + if got := r.URL.Query()["status-types"]; len(got) != 2 || got[0] != "unread" || got[1] != "pinned" { + t.Errorf("got status-types=%v, want [unread pinned]", got) + } + if got := r.URL.Query().Get("to-status"); got != "read" { + t.Errorf("got to-status=%q, want read", got) + } + if got := r.URL.Query().Get("last_read_at"); got != lastReadAt.Format(time.RFC3339) { + t.Errorf("got last_read_at=%q, want %q", got, lastReadAt.Format(time.RFC3339)) + } + w.WriteHeader(http.StatusResetContent) + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 21, Unread: false, Subject: &types.NotificationSubject{Title: "Release notes"}}, + {ID: 22, Unread: false, Subject: &types.NotificationSubject{Title: "Issue triaged"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.MarkNotifications(context.Background(), &NotificationMarkOptions{ + All: true, + StatusTypes: []string{"unread", "pinned"}, + ToStatus: "read", + LastReadAt: &lastReadAt, + }) + if err != nil { + t.Fatal(err) + } + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + if threads[0].ID != 21 || threads[1].ID != 22 { + t.Fatalf("got ids=%d,%d want 21,22", threads[0].ID, threads[1].ID) + } + if threads[0].Subject.Title != "Release notes" { + t.Errorf("got title=%q, want %q", threads[0].Subject.Title, "Release notes") + } +} + +func TestNotificationService_MarkRead_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Errorf("expected PUT, got %s", r.Method) @@ -126,7 +286,7 @@ func TestNotificationService_Good_MarkRead(t *testing.T) { } } -func TestNotificationService_Good_MarkThreadRead(t *testing.T) { +func TestNotificationService_MarkThreadRead_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) @@ -145,7 +305,57 @@ func TestNotificationService_Good_MarkThreadRead(t *testing.T) { } } -func TestNotificationService_Bad_NotFound(t *testing.T) { +func TestNotificationService_MarkRepoNotifications_Good(t *testing.T) { + lastReadAt := time.Date(2026, time.April, 2, 15, 4, 5, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query()["status-types"]; len(got) != 2 || got[0] != "unread" || got[1] != "pinned" { + t.Errorf("got status-types=%v, want [unread pinned]", got) + } + if got := r.URL.Query().Get("all"); got != "true" { + t.Errorf("got all=%q, want true", got) + } + if got := r.URL.Query().Get("to-status"); got != "read" { + t.Errorf("got to-status=%q, want read", got) + } + if got := r.URL.Query().Get("last_read_at"); got != lastReadAt.Format(time.RFC3339) { + t.Errorf("got last_read_at=%q, want %q", got, lastReadAt.Format(time.RFC3339)) + } + w.WriteHeader(http.StatusResetContent) + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 7, Unread: false, Subject: &types.NotificationSubject{Title: "Pinned release"}}, + {ID: 8, Unread: false, Subject: &types.NotificationSubject{Title: "New docs"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.MarkRepoNotifications(context.Background(), "core", "go-forge", &NotificationRepoMarkOptions{ + All: true, + StatusTypes: []string{"unread", "pinned"}, + ToStatus: "read", + LastReadAt: &lastReadAt, + }) + if err != nil { + t.Fatal(err) + } + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + if threads[0].ID != 7 || threads[1].ID != 8 { + t.Fatalf("got ids=%d,%d want 7,8", threads[0].ID, threads[1].ID) + } + if threads[0].Subject.Title != "Pinned release" { + t.Errorf("got title=%q, want %q", threads[0].Subject.Title, "Pinned release") + } +} + +func TestNotificationService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "thread not found"}) diff --git a/orgs.go b/orgs.go index 36d4dc2..c76540f 100644 --- a/orgs.go +++ b/orgs.go @@ -2,17 +2,49 @@ package forge import ( "context" - "fmt" "iter" + "net/http" + "time" "dappco.re/go/core/forge/types" ) // OrgService handles organisation operations. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Orgs.ListMembers(ctx, "core") type OrgService struct { Resource[types.Organization, types.CreateOrgOption, types.EditOrgOption] } +// OrgActivityFeedListOptions controls filtering for organisation activity feeds. +// +// Usage: +// +// opts := forge.OrgActivityFeedListOptions{Date: &day} +type OrgActivityFeedListOptions struct { + Date *time.Time +} + +// String returns a safe summary of the organisation activity feed filters. +func (o OrgActivityFeedListOptions) String() string { + return optionString("forge.OrgActivityFeedListOptions", "date", o.Date) +} + +// GoString returns a safe Go-syntax summary of the organisation activity feed filters. +func (o OrgActivityFeedListOptions) GoString() string { return o.String() } + +func (o OrgActivityFeedListOptions) queryParams() map[string]string { + if o.Date == nil { + return nil + } + return map[string]string{ + "date": o.Date.Format("2006-01-02"), + } +} + func newOrgService(c *Client) *OrgService { return &OrgService{ Resource: *NewResource[types.Organization, types.CreateOrgOption, types.EditOrgOption]( @@ -21,39 +53,257 @@ func newOrgService(c *Client) *OrgService { } } +// ListOrgs returns all organisations. +func (s *OrgService) ListOrgs(ctx context.Context) ([]types.Organization, error) { + return ListAll[types.Organization](ctx, s.client, "/api/v1/orgs", nil) +} + +// IterOrgs returns an iterator over all organisations. +func (s *OrgService) IterOrgs(ctx context.Context) iter.Seq2[types.Organization, error] { + return ListIter[types.Organization](ctx, s.client, "/api/v1/orgs", nil) +} + +// CreateOrg creates a new organisation. +func (s *OrgService) CreateOrg(ctx context.Context, opts *types.CreateOrgOption) (*types.Organization, error) { + var out types.Organization + if err := s.client.Post(ctx, "/api/v1/orgs", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // ListMembers returns all members of an organisation. func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/members", org) + path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListAll[types.User](ctx, s.client, path, nil) } // IterMembers returns an iterator over all members of an organisation. func (s *OrgService) IterMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/members", org) + path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListIter[types.User](ctx, s.client, path, nil) } // AddMember adds a user to an organisation. func (s *OrgService) AddMember(ctx context.Context, org, username string) error { - path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + path := ResolvePath("/api/v1/orgs/{org}/members/{username}", pathParams("org", org, "username", username)) return s.client.Put(ctx, path, nil, nil) } // RemoveMember removes a user from an organisation. func (s *OrgService) RemoveMember(ctx context.Context, org, username string) error { - path := fmt.Sprintf("/api/v1/orgs/%s/members/%s", org, username) + path := ResolvePath("/api/v1/orgs/{org}/members/{username}", pathParams("org", org, "username", username)) + return s.client.Delete(ctx, path) +} + +// IsMember reports whether a user is a member of an organisation. +func (s *OrgService) IsMember(ctx context.Context, org, username string) (bool, error) { + path := ResolvePath("/api/v1/orgs/{org}/members/{username}", pathParams("org", org, "username", username)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} + +// ListBlockedUsers returns all users blocked by an organisation. +func (s *OrgService) ListBlockedUsers(ctx context.Context, org string) ([]types.BlockedUser, error) { + path := ResolvePath("/api/v1/orgs/{org}/list_blocked", pathParams("org", org)) + return ListAll[types.BlockedUser](ctx, s.client, path, nil) +} + +// IterBlockedUsers returns an iterator over all users blocked by an organisation. +func (s *OrgService) IterBlockedUsers(ctx context.Context, org string) iter.Seq2[types.BlockedUser, error] { + path := ResolvePath("/api/v1/orgs/{org}/list_blocked", pathParams("org", org)) + return ListIter[types.BlockedUser](ctx, s.client, path, nil) +} + +// IsBlocked reports whether a user is blocked by an organisation. +func (s *OrgService) IsBlocked(ctx context.Context, org, username string) (bool, error) { + path := ResolvePath("/api/v1/orgs/{org}/block/{username}", pathParams("org", org, "username", username)) + resp, err := s.client.doJSON(ctx, "GET", path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} + +// ListPublicMembers returns all public members of an organisation. +func (s *OrgService) ListPublicMembers(ctx context.Context, org string) ([]types.User, error) { + path := ResolvePath("/api/v1/orgs/{org}/public_members", pathParams("org", org)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterPublicMembers returns an iterator over all public members of an organisation. +func (s *OrgService) IterPublicMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/orgs/{org}/public_members", pathParams("org", org)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// IsPublicMember reports whether a user is a public member of an organisation. +func (s *OrgService) IsPublicMember(ctx context.Context, org, username string) (bool, error) { + path := ResolvePath("/api/v1/orgs/{org}/public_members/{username}", pathParams("org", org, "username", username)) + resp, err := s.client.doJSON(ctx, "GET", path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} + +// PublicizeMember makes a user's membership public within an organisation. +func (s *OrgService) PublicizeMember(ctx context.Context, org, username string) error { + path := ResolvePath("/api/v1/orgs/{org}/public_members/{username}", pathParams("org", org, "username", username)) + return s.client.Put(ctx, path, nil, nil) +} + +// ConcealMember hides a user's public membership within an organisation. +func (s *OrgService) ConcealMember(ctx context.Context, org, username string) error { + path := ResolvePath("/api/v1/orgs/{org}/public_members/{username}", pathParams("org", org, "username", username)) + return s.client.Delete(ctx, path) +} + +// Block blocks a user within an organisation. +func (s *OrgService) Block(ctx context.Context, org, username string) error { + path := ResolvePath("/api/v1/orgs/{org}/block/{username}", pathParams("org", org, "username", username)) + return s.client.Put(ctx, path, nil, nil) +} + +// Unblock unblocks a user within an organisation. +func (s *OrgService) Unblock(ctx context.Context, org, username string) error { + path := ResolvePath("/api/v1/orgs/{org}/unblock/{username}", pathParams("org", org, "username", username)) return s.client.Delete(ctx, path) } +// GetQuota returns the quota information for an organisation. +func (s *OrgService) GetQuota(ctx context.Context, org string) (*types.QuotaInfo, error) { + path := ResolvePath("/api/v1/orgs/{org}/quota", pathParams("org", org)) + var out types.QuotaInfo + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CheckQuota reports whether an organisation is over quota for the current subject. +func (s *OrgService) CheckQuota(ctx context.Context, org string) (bool, error) { + path := ResolvePath("/api/v1/orgs/{org}/quota/check", pathParams("org", org)) + var out bool + if err := s.client.Get(ctx, path, &out); err != nil { + return false, err + } + return out, nil +} + +// ListQuotaArtifacts returns all artefacts counting towards an organisation's quota. +func (s *OrgService) ListQuotaArtifacts(ctx context.Context, org string) ([]types.QuotaUsedArtifact, error) { + path := ResolvePath("/api/v1/orgs/{org}/quota/artifacts", pathParams("org", org)) + return ListAll[types.QuotaUsedArtifact](ctx, s.client, path, nil) +} + +// IterQuotaArtifacts returns an iterator over all artefacts counting towards an organisation's quota. +func (s *OrgService) IterQuotaArtifacts(ctx context.Context, org string) iter.Seq2[types.QuotaUsedArtifact, error] { + path := ResolvePath("/api/v1/orgs/{org}/quota/artifacts", pathParams("org", org)) + return ListIter[types.QuotaUsedArtifact](ctx, s.client, path, nil) +} + +// ListQuotaAttachments returns all attachments counting towards an organisation's quota. +func (s *OrgService) ListQuotaAttachments(ctx context.Context, org string) ([]types.QuotaUsedAttachment, error) { + path := ResolvePath("/api/v1/orgs/{org}/quota/attachments", pathParams("org", org)) + return ListAll[types.QuotaUsedAttachment](ctx, s.client, path, nil) +} + +// IterQuotaAttachments returns an iterator over all attachments counting towards an organisation's quota. +func (s *OrgService) IterQuotaAttachments(ctx context.Context, org string) iter.Seq2[types.QuotaUsedAttachment, error] { + path := ResolvePath("/api/v1/orgs/{org}/quota/attachments", pathParams("org", org)) + return ListIter[types.QuotaUsedAttachment](ctx, s.client, path, nil) +} + +// ListQuotaPackages returns all packages counting towards an organisation's quota. +func (s *OrgService) ListQuotaPackages(ctx context.Context, org string) ([]types.QuotaUsedPackage, error) { + path := ResolvePath("/api/v1/orgs/{org}/quota/packages", pathParams("org", org)) + return ListAll[types.QuotaUsedPackage](ctx, s.client, path, nil) +} + +// IterQuotaPackages returns an iterator over all packages counting towards an organisation's quota. +func (s *OrgService) IterQuotaPackages(ctx context.Context, org string) iter.Seq2[types.QuotaUsedPackage, error] { + path := ResolvePath("/api/v1/orgs/{org}/quota/packages", pathParams("org", org)) + return ListIter[types.QuotaUsedPackage](ctx, s.client, path, nil) +} + +// GetRunnerRegistrationToken returns an organisation actions runner registration token. +func (s *OrgService) GetRunnerRegistrationToken(ctx context.Context, org string) (string, error) { + path := ResolvePath("/api/v1/orgs/{org}/actions/runners/registration-token", pathParams("org", org)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return "", err + } + return resp.Header.Get("token"), nil +} + +// UpdateAvatar updates an organisation avatar. +func (s *OrgService) UpdateAvatar(ctx context.Context, org string, opts *types.UpdateUserAvatarOption) error { + path := ResolvePath("/api/v1/orgs/{org}/avatar", pathParams("org", org)) + return s.client.Post(ctx, path, opts, nil) +} + +// DeleteAvatar deletes an organisation avatar. +func (s *OrgService) DeleteAvatar(ctx context.Context, org string) error { + path := ResolvePath("/api/v1/orgs/{org}/avatar", pathParams("org", org)) + return s.client.Delete(ctx, path) +} + +// SearchTeams searches for teams within an organisation. +func (s *OrgService) SearchTeams(ctx context.Context, org, q string) ([]types.Team, error) { + path := ResolvePath("/api/v1/orgs/{org}/teams/search", pathParams("org", org)) + return ListAll[types.Team](ctx, s.client, path, map[string]string{"q": q}) +} + +// IterSearchTeams returns an iterator over teams within an organisation. +func (s *OrgService) IterSearchTeams(ctx context.Context, org, q string) iter.Seq2[types.Team, error] { + path := ResolvePath("/api/v1/orgs/{org}/teams/search", pathParams("org", org)) + return ListIter[types.Team](ctx, s.client, path, map[string]string{"q": q}) +} + +// GetUserPermissions returns a user's permissions in an organisation. +func (s *OrgService) GetUserPermissions(ctx context.Context, username, org string) (*types.OrganizationPermissions, error) { + path := ResolvePath("/api/v1/users/{username}/orgs/{org}/permissions", pathParams("username", username, "org", org)) + var out types.OrganizationPermissions + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListActivityFeeds returns the organisation's activity feed entries. +func (s *OrgService) ListActivityFeeds(ctx context.Context, org string, filters ...OrgActivityFeedListOptions) ([]types.Activity, error) { + path := ResolvePath("/api/v1/orgs/{org}/activities/feeds", pathParams("org", org)) + return ListAll[types.Activity](ctx, s.client, path, orgActivityFeedQuery(filters...)) +} + +// IterActivityFeeds returns an iterator over the organisation's activity feed entries. +func (s *OrgService) IterActivityFeeds(ctx context.Context, org string, filters ...OrgActivityFeedListOptions) iter.Seq2[types.Activity, error] { + path := ResolvePath("/api/v1/orgs/{org}/activities/feeds", pathParams("org", org)) + return ListIter[types.Activity](ctx, s.client, path, orgActivityFeedQuery(filters...)) +} + // ListUserOrgs returns all organisations for a user. func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error) { - path := fmt.Sprintf("/api/v1/users/%s/orgs", username) + path := ResolvePath("/api/v1/users/{username}/orgs", pathParams("username", username)) return ListAll[types.Organization](ctx, s.client, path, nil) } // IterUserOrgs returns an iterator over all organisations for a user. func (s *OrgService) IterUserOrgs(ctx context.Context, username string) iter.Seq2[types.Organization, error] { - path := fmt.Sprintf("/api/v1/users/%s/orgs", username) + path := ResolvePath("/api/v1/users/{username}/orgs", pathParams("username", username)) return ListIter[types.Organization](ctx, s.client, path, nil) } @@ -66,3 +316,20 @@ func (s *OrgService) ListMyOrgs(ctx context.Context) ([]types.Organization, erro func (s *OrgService) IterMyOrgs(ctx context.Context) iter.Seq2[types.Organization, error] { return ListIter[types.Organization](ctx, s.client, "/api/v1/user/orgs", nil) } + +func orgActivityFeedQuery(filters ...OrgActivityFeedListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 1) + for _, filter := range filters { + if filter.Date != nil { + query["date"] = filter.Date.Format("2006-01-02") + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/orgs_extra_test.go b/orgs_extra_test.go new file mode 100644 index 0000000..348333b --- /dev/null +++ b/orgs_extra_test.go @@ -0,0 +1,63 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestOrgService_ListOrgs_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Organization{{ID: 1, Name: "core"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + orgs, err := f.Orgs.ListOrgs(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(orgs) != 1 || orgs[0].Name != "core" { + t.Fatalf("got %#v", orgs) + } +} + +func TestOrgService_CreateOrg_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateOrgOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.UserName != "core" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.Organization{ID: 1, Name: body.UserName}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + org, err := f.Orgs.CreateOrg(context.Background(), &types.CreateOrgOption{UserName: "core"}) + if err != nil { + t.Fatal(err) + } + if org.Name != "core" { + t.Fatalf("got name=%q", org.Name) + } +} diff --git a/orgs_test.go b/orgs_test.go index bec5198..81ac6d6 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -2,15 +2,16 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" + "time" "dappco.re/go/core/forge/types" ) -func TestOrgService_Good_List(t *testing.T) { +func TestOrgService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -36,7 +37,7 @@ func TestOrgService_Good_List(t *testing.T) { } } -func TestOrgService_Good_Get(t *testing.T) { +func TestOrgService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -58,7 +59,7 @@ func TestOrgService_Good_Get(t *testing.T) { } } -func TestOrgService_Good_ListMembers(t *testing.T) { +func TestOrgService_ListMembers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -86,3 +87,265 @@ func TestOrgService_Good_ListMembers(t *testing.T) { t.Errorf("got username=%q, want %q", members[0].UserName, "alice") } } + +func TestOrgService_IsMember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + member, err := f.Orgs.IsMember(context.Background(), "core", "alice") + if err != nil { + t.Fatal(err) + } + if !member { + t.Fatal("got member=false, want true") + } +} + +func TestOrgService_ListPublicMembers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/public_members" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + members, err := f.Orgs.ListPublicMembers(context.Background(), "core") + if err != nil { + t.Fatal(err) + } + if len(members) != 2 { + t.Errorf("got %d members, want 2", len(members)) + } + if members[0].UserName != "alice" { + t.Errorf("got username=%q, want %q", members[0].UserName, "alice") + } +} + +func TestOrgService_ListBlockedUsers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/list_blocked" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.BlockedUser{ + {BlockID: 1}, + {BlockID: 2}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + blocked, err := f.Orgs.ListBlockedUsers(context.Background(), "core") + if err != nil { + t.Fatal(err) + } + if len(blocked) != 2 { + t.Fatalf("got %d blocked users, want 2", len(blocked)) + } + if blocked[0].BlockID != 1 { + t.Errorf("got block_id=%d, want %d", blocked[0].BlockID, 1) + } +} + +func TestOrgService_PublicizeMember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/public_members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Orgs.PublicizeMember(context.Background(), "core", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestOrgService_ConcealMember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/public_members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Orgs.ConcealMember(context.Background(), "core", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestOrgService_Block_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/block/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Orgs.Block(context.Background(), "core", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestOrgService_Unblock_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/unblock/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Orgs.Unblock(context.Background(), "core", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestOrgService_ListActivityFeeds_Good(t *testing.T) { + date := time.Date(2026, time.April, 2, 15, 4, 5, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/activities/feeds" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("date"); got != "2026-04-02" { + t.Errorf("wrong date: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Activity{{ + ID: 9, + OpType: "create_org", + Content: "created organisation", + }}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + activities, err := f.Orgs.ListActivityFeeds(context.Background(), "core", OrgActivityFeedListOptions{Date: &date}) + if err != nil { + t.Fatal(err) + } + if len(activities) != 1 || activities[0].ID != 9 || activities[0].OpType != "create_org" { + t.Fatalf("got %#v", activities) + } +} + +func TestOrgService_IterActivityFeeds_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/activities/feeds" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Activity{{ + ID: 11, + OpType: "update_org", + Content: "updated organisation", + }}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []int64 + for activity, err := range f.Orgs.IterActivityFeeds(context.Background(), "core") { + if err != nil { + t.Fatal(err) + } + got = append(got, activity.ID) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 1 || got[0] != 11 { + t.Fatalf("got %#v", got) + } +} + +func TestOrgService_IsBlocked_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/block/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + blocked, err := f.Orgs.IsBlocked(context.Background(), "core", "alice") + if err != nil { + t.Fatal(err) + } + if !blocked { + t.Fatal("got blocked=false, want true") + } +} + +func TestOrgService_IsPublicMember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/public_members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + public, err := f.Orgs.IsPublicMember(context.Background(), "core", "alice") + if err != nil { + t.Fatal(err) + } + if !public { + t.Fatal("got public=false, want true") + } +} diff --git a/packages.go b/packages.go index ee724ea..43784a1 100644 --- a/packages.go +++ b/packages.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -10,6 +9,11 @@ import ( // PackageService handles package registry operations via the Forgejo API. // No Resource embedding — paths vary by operation. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Packages.List(ctx, "core") type PackageService struct { client *Client } @@ -20,19 +24,19 @@ func newPackageService(c *Client) *PackageService { // List returns all packages for a given owner. func (s *PackageService) List(ctx context.Context, owner string) ([]types.Package, error) { - path := fmt.Sprintf("/api/v1/packages/%s", owner) + path := ResolvePath("/api/v1/packages/{owner}", pathParams("owner", owner)) return ListAll[types.Package](ctx, s.client, path, nil) } // Iter returns an iterator over all packages for a given owner. func (s *PackageService) Iter(ctx context.Context, owner string) iter.Seq2[types.Package, error] { - path := fmt.Sprintf("/api/v1/packages/%s", owner) + path := ResolvePath("/api/v1/packages/{owner}", pathParams("owner", owner)) return ListIter[types.Package](ctx, s.client, path, nil) } // Get returns a single package by owner, type, name, and version. func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version string) (*types.Package, error) { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{type}/{name}/{version}", pathParams("owner", owner, "type", pkgType, "name", name, "version", version)) var out types.Package if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,18 +46,18 @@ func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version // Delete removes a package by owner, type, name, and version. func (s *PackageService) Delete(ctx context.Context, owner, pkgType, name, version string) error { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{type}/{name}/{version}", pathParams("owner", owner, "type", pkgType, "name", name, "version", version)) return s.client.Delete(ctx, path) } // ListFiles returns all files for a specific package version. func (s *PackageService) ListFiles(ctx context.Context, owner, pkgType, name, version string) ([]types.PackageFile, error) { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{type}/{name}/{version}/files", pathParams("owner", owner, "type", pkgType, "name", name, "version", version)) return ListAll[types.PackageFile](ctx, s.client, path, nil) } // IterFiles returns an iterator over all files for a specific package version. func (s *PackageService) IterFiles(ctx context.Context, owner, pkgType, name, version string) iter.Seq2[types.PackageFile, error] { - path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{type}/{name}/{version}/files", pathParams("owner", owner, "type", pkgType, "name", name, "version", version)) return ListIter[types.PackageFile](ctx, s.client, path, nil) } diff --git a/packages_test.go b/packages_test.go index a8cfc8d..a457e82 100644 --- a/packages_test.go +++ b/packages_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestPackageService_Good_List(t *testing.T) { +func TestPackageService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -42,7 +42,7 @@ func TestPackageService_Good_List(t *testing.T) { } } -func TestPackageService_Good_Get(t *testing.T) { +func TestPackageService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -75,7 +75,7 @@ func TestPackageService_Good_Get(t *testing.T) { } } -func TestPackageService_Good_Delete(t *testing.T) { +func TestPackageService_Delete_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -94,7 +94,7 @@ func TestPackageService_Good_Delete(t *testing.T) { } } -func TestPackageService_Good_ListFiles(t *testing.T) { +func TestPackageService_ListFiles_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -125,7 +125,7 @@ func TestPackageService_Good_ListFiles(t *testing.T) { } } -func TestPackageService_Bad_NotFound(t *testing.T) { +func TestPackageService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "package not found"}) diff --git a/pagination.go b/pagination.go index 26e8565..203b15a 100644 --- a/pagination.go +++ b/pagination.go @@ -7,19 +7,58 @@ import ( "net/url" "strconv" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" ) +const defaultPageLimit = 50 + // ListOptions controls pagination. +// +// Usage: +// +// opts := forge.ListOptions{Page: 1, Limit: 50} +// _ = opts type ListOptions struct { Page int // 1-based page number Limit int // items per page (default 50) } -// DefaultList returns sensible default pagination. -var DefaultList = ListOptions{Page: 1, Limit: 50} +// String returns a safe summary of the pagination options. +// +// Usage: +// +// _ = forge.DefaultList.String() +func (o ListOptions) String() string { + return core.Concat( + "forge.ListOptions{page=", + strconv.Itoa(o.Page), + ", limit=", + strconv.Itoa(o.Limit), + "}", + ) +} + +// GoString returns a safe Go-syntax summary of the pagination options. +// +// Usage: +// +// _ = fmt.Sprintf("%#v", forge.DefaultList) +func (o ListOptions) GoString() string { return o.String() } + +// DefaultList provides sensible default pagination. +// +// Usage: +// +// page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) +// _ = page +var DefaultList = ListOptions{Page: 1, Limit: defaultPageLimit} // PagedResult holds a single page of results with metadata. +// +// Usage: +// +// page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) +// _ = page type PagedResult[T any] struct { Items []T TotalCount int @@ -27,19 +66,55 @@ type PagedResult[T any] struct { HasMore bool } +// String returns a safe summary of a page of results. +// +// Usage: +// +// page, _ := forge.ListPage[types.Repository](...) +// _ = page.String() +func (r PagedResult[T]) String() string { + items := 0 + if r.Items != nil { + items = len(r.Items) + } + return core.Concat( + "forge.PagedResult{items=", + strconv.Itoa(items), + ", totalCount=", + strconv.Itoa(r.TotalCount), + ", page=", + strconv.Itoa(r.Page), + ", hasMore=", + strconv.FormatBool(r.HasMore), + "}", + ) +} + +// GoString returns a safe Go-syntax summary of a page of results. +// +// Usage: +// +// _ = fmt.Sprintf("%#v", page) +func (r PagedResult[T]) GoString() string { return r.String() } + // ListPage fetches a single page of results. // Extra query params can be passed via the query map. +// +// Usage: +// +// page, err := forge.ListPage[types.Repository](ctx, client, "/api/v1/user/repos", nil, forge.DefaultList) +// _ = page func ListPage[T any](ctx context.Context, c *Client, path string, query map[string]string, opts ListOptions) (*PagedResult[T], error) { if opts.Page < 1 { opts.Page = 1 } if opts.Limit < 1 { - opts.Limit = 50 + opts.Limit = defaultPageLimit } u, err := url.Parse(path) if err != nil { - return nil, coreerr.E("ListPage", "forge: parse path", err) + return nil, core.E("ListPage", "forge: parse path", err) } q := u.Query() @@ -70,12 +145,17 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri } // ListAll fetches all pages of results. +// +// Usage: +// +// items, err := forge.ListAll[types.Repository](ctx, client, "/api/v1/user/repos", nil) +// _ = items func ListAll[T any](ctx context.Context, c *Client, path string, query map[string]string) ([]T, error) { var all []T page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: 50}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) if err != nil { return nil, err } @@ -90,11 +170,17 @@ func ListAll[T any](ctx context.Context, c *Client, path string, query map[strin } // ListIter returns an iterator over all resources across all pages. +// +// Usage: +// +// for item, err := range forge.ListIter[types.Repository](ctx, client, "/api/v1/user/repos", nil) { +// _, _ = item, err +// } func ListIter[T any](ctx context.Context, c *Client, path string, query map[string]string) iter.Seq2[T, error] { return func(yield func(T, error) bool) { page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: 50}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) if err != nil { yield(*new(T), err) return diff --git a/pagination_test.go b/pagination_test.go index 61e047e..89e650c 100644 --- a/pagination_test.go +++ b/pagination_test.go @@ -2,13 +2,13 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" ) -func TestPagination_Good_SinglePage(t *testing.T) { +func TestPagination_SinglePage_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "2") json.NewEncoder(w).Encode([]map[string]int{{"id": 1}, {"id": 2}}) @@ -25,7 +25,7 @@ func TestPagination_Good_SinglePage(t *testing.T) { } } -func TestPagination_Good_MultiPage(t *testing.T) { +func TestPagination_MultiPage_Good(t *testing.T) { page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page++ @@ -48,7 +48,7 @@ func TestPagination_Good_MultiPage(t *testing.T) { } } -func TestPagination_Good_EmptyResult(t *testing.T) { +func TestPagination_EmptyResult_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "0") json.NewEncoder(w).Encode([]map[string]int{}) @@ -65,7 +65,7 @@ func TestPagination_Good_EmptyResult(t *testing.T) { } } -func TestPagination_Good_Iter(t *testing.T) { +func TestPagination_Iter_Good(t *testing.T) { page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page++ @@ -95,7 +95,7 @@ func TestPagination_Good_Iter(t *testing.T) { } } -func TestListPage_Good_QueryParams(t *testing.T) { +func TestListPage_QueryParams_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Query().Get("page") l := r.URL.Query().Get("limit") @@ -116,7 +116,7 @@ func TestListPage_Good_QueryParams(t *testing.T) { } } -func TestPagination_Bad_ServerError(t *testing.T) { +func TestPagination_ServerError_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) json.NewEncoder(w).Encode(map[string]string{"message": "fail"}) diff --git a/params.go b/params.go index ce20aaf..50c291f 100644 --- a/params.go +++ b/params.go @@ -2,17 +2,68 @@ package forge import ( "net/url" + "sort" + "strconv" "strings" + + core "dappco.re/go/core" ) // Params maps path variable names to values. // Example: Params{"owner": "core", "repo": "go-forge"} +// +// Usage: +// +// params := forge.Params{"owner": "core", "repo": "go-forge"} +// _ = params type Params map[string]string +// String returns a safe summary of the path parameters. +// +// Usage: +// +// _ = forge.Params{"owner": "core"}.String() +func (p Params) String() string { + if p == nil { + return "forge.Params{}" + } + + keys := make([]string, 0, len(p)) + for k := range p { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("forge.Params{") + for i, k := range keys { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(k) + b.WriteString("=") + b.WriteString(strconv.Quote(p[k])) + } + b.WriteString("}") + return b.String() +} + +// GoString returns a safe Go-syntax summary of the path parameters. +// +// Usage: +// +// _ = fmt.Sprintf("%#v", forge.Params{"owner": "core"}) +func (p Params) GoString() string { return p.String() } + // ResolvePath substitutes {placeholders} in path with values from params. +// +// Usage: +// +// path := forge.ResolvePath("/api/v1/repos/{owner}/{repo}", forge.Params{"owner": "core", "repo": "go-forge"}) +// _ = path func ResolvePath(path string, params Params) string { for k, v := range params { - path = strings.ReplaceAll(path, "{"+k+"}", url.PathEscape(v)) + path = core.Replace(path, "{"+k+"}", url.PathEscape(v)) } return path } diff --git a/params_test.go b/params_test.go index b82d2a3..7d20f33 100644 --- a/params_test.go +++ b/params_test.go @@ -2,7 +2,7 @@ package forge import "testing" -func TestResolvePath_Good_Simple(t *testing.T) { +func TestResolvePath_Simple_Good(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}", Params{"owner": "core", "repo": "go-forge"}) want := "/api/v1/repos/core/go-forge" if got != want { @@ -10,14 +10,14 @@ func TestResolvePath_Good_Simple(t *testing.T) { } } -func TestResolvePath_Good_NoParams(t *testing.T) { +func TestResolvePath_NoParams_Good(t *testing.T) { got := ResolvePath("/api/v1/user", nil) if got != "/api/v1/user" { t.Errorf("got %q", got) } } -func TestResolvePath_Good_WithID(t *testing.T) { +func TestResolvePath_WithID_Good(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}", Params{ "owner": "core", "repo": "go-forge", "index": "42", }) @@ -27,7 +27,7 @@ func TestResolvePath_Good_WithID(t *testing.T) { } } -func TestResolvePath_Good_URLEncoding(t *testing.T) { +func TestResolvePath_URLEncoding_Good(t *testing.T) { got := ResolvePath("/api/v1/repos/{owner}/{repo}", Params{"owner": "my org", "repo": "my repo"}) want := "/api/v1/repos/my%20org/my%20repo" if got != want { diff --git a/pulls.go b/pulls.go index 408f438..56cd6eb 100644 --- a/pulls.go +++ b/pulls.go @@ -2,17 +2,71 @@ package forge import ( "context" - "fmt" "iter" + "net/url" + "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // PullService handles pull request operations within a repository. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Pulls.ListAll(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type PullService struct { Resource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption] } +// PullListOptions controls filtering for repository pull request listings. +// +// Usage: +// +// opts := forge.PullListOptions{State: "open", Labels: []int64{1, 2}} +type PullListOptions struct { + State string + Sort string + Milestone int64 + Labels []int64 + Poster string +} + +// String returns a safe summary of the pull request list filters. +func (o PullListOptions) String() string { + return optionString("forge.PullListOptions", + "state", o.State, + "sort", o.Sort, + "milestone", o.Milestone, + "labels", o.Labels, + "poster", o.Poster, + ) +} + +// GoString returns a safe Go-syntax summary of the pull request list filters. +func (o PullListOptions) GoString() string { return o.String() } + +func (o PullListOptions) addQuery(values url.Values) { + if o.State != "" { + values.Set("state", o.State) + } + if o.Sort != "" { + values.Set("sort", o.Sort) + } + if o.Milestone != 0 { + values.Set("milestone", strconv.FormatInt(o.Milestone, 10)) + } + for _, label := range o.Labels { + if label != 0 { + values.Add("labels", strconv.FormatInt(label, 10)) + } + } + if o.Poster != "" { + values.Set("poster", o.Poster) + } +} + func newPullService(c *Client) *PullService { return &PullService{ Resource: *NewResource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption]( @@ -21,34 +75,132 @@ func newPullService(c *Client) *PullService { } } +// ListPullRequests returns all pull requests in a repository. +func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { + return s.listAll(ctx, owner, repo, filters...) +} + +// IterPullRequests returns an iterator over all pull requests in a repository. +func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { + return s.listIter(ctx, owner, repo, filters...) +} + +// CreatePullRequest creates a pull request in a repository. +func (s *PullService) CreatePullRequest(ctx context.Context, owner, repo string, opts *types.CreatePullRequestOption) (*types.PullRequest, error) { + var out types.PullRequest + if err := s.client.Post(ctx, ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)), opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // Merge merges a pull request. Method is one of "merge", "rebase", "rebase-merge", "squash", "fast-forward-only", "manually-merged". func (s *PullService) Merge(ctx context.Context, owner, repo string, index int64, method string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", pathParams("owner", owner, "repo", repo, "index", int64String(index))) body := map[string]string{"Do": method} return s.client.Post(ctx, path, body, nil) } +// CancelScheduledAutoMerge cancels the scheduled auto merge for a pull request. +func (s *PullService) CancelScheduledAutoMerge(ctx context.Context, owner, repo string, index int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.Delete(ctx, path) +} + // Update updates a pull request branch with the base branch. func (s *PullService) Update(ctx context.Context, owner, repo string, index int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/update", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/update", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return s.client.Post(ctx, path, nil, nil) } +// GetDiffOrPatch returns a pull request diff or patch as raw bytes. +func (s *PullService) GetDiffOrPatch(ctx context.Context, owner, repo string, index int64, diffType string) ([]byte, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}.{diffType}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "diffType", diffType)) + return s.client.GetRaw(ctx, path) +} + +// ListCommits returns all commits for a pull request. +func (s *PullService) ListCommits(ctx context.Context, owner, repo string, index int64) ([]types.Commit, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/commits", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.Commit](ctx, s.client, path, nil) +} + +// IterCommits returns an iterator over all commits for a pull request. +func (s *PullService) IterCommits(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Commit, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/commits", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.Commit](ctx, s.client, path, nil) +} + // ListReviews returns all reviews on a pull request. func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListAll[types.PullReview](ctx, s.client, path, nil) } // IterReviews returns an iterator over all reviews on a pull request. func (s *PullService) IterReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.PullReview](ctx, s.client, path, nil) } +// ListFiles returns all changed files on a pull request. +func (s *PullService) ListFiles(ctx context.Context, owner, repo string, index int64) ([]types.ChangedFile, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/files", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListAll[types.ChangedFile](ctx, s.client, path, nil) +} + +// IterFiles returns an iterator over all changed files on a pull request. +func (s *PullService) IterFiles(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.ChangedFile, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/files", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return ListIter[types.ChangedFile](ctx, s.client, path, nil) +} + +// GetByBaseHead returns a pull request for a given base and head branch pair. +func (s *PullService) GetByBaseHead(ctx context.Context, owner, repo, base, head string) (*types.PullRequest, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{base}/{head}", pathParams( + "owner", owner, + "repo", repo, + "base", base, + "head", head, + )) + var out types.PullRequest + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListReviewers returns all users who can be requested to review a pull request. +func (s *PullService) ListReviewers(ctx context.Context, owner, repo string) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/reviewers", pathParams("owner", owner, "repo", repo)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterReviewers returns an iterator over all users who can be requested to review a pull request. +func (s *PullService) IterReviewers(ctx context.Context, owner, repo string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/reviewers", pathParams("owner", owner, "repo", repo)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// RequestReviewers creates review requests for a pull request. +func (s *PullService) RequestReviewers(ctx context.Context, owner, repo string, index int64, opts *types.PullReviewRequestOptions) ([]types.PullReview, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/requested_reviewers", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + var out []types.PullReview + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return out, nil +} + +// CancelReviewRequests cancels review requests for a pull request. +func (s *PullService) CancelReviewRequests(ctx context.Context, owner, repo string, index int64, opts *types.PullReviewRequestOptions) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/requested_reviewers", pathParams("owner", owner, "repo", repo, "index", int64String(index))) + return s.client.DeleteWithBody(ctx, path, opts) +} + // SubmitReview creates a new review on a pull request. -func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, index int64, review map[string]any) (*types.PullReview, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) +func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, index int64, review *types.SubmitPullReviewOptions) (*types.PullReview, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) var out types.PullReview if err := s.client.Post(ctx, path, review, &out); err != nil { return nil, err @@ -56,15 +208,148 @@ func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, inde return &out, nil } +// GetReview returns a single pull request review. +func (s *PullService) GetReview(ctx context.Context, owner, repo string, index, reviewID int64) (*types.PullReview, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) + var out types.PullReview + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteReview deletes a pull request review. +func (s *PullService) DeleteReview(ctx context.Context, owner, repo string, index, reviewID int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) + return s.client.Delete(ctx, path) +} + +func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...PullListOptions) (*PagedResult[types.PullRequest], error) { + if opts.Page < 1 { + opts.Page = 1 + } + if opts.Limit < 1 { + opts.Limit = defaultPageLimit + } + + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) + u, err := url.Parse(path) + if err != nil { + return nil, core.E("PullService.listPage", "forge: parse path", err) + } + + values := u.Query() + values.Set("page", strconv.Itoa(opts.Page)) + values.Set("limit", strconv.Itoa(opts.Limit)) + for _, filter := range filters { + filter.addQuery(values) + } + u.RawQuery = values.Encode() + + var items []types.PullRequest + resp, err := s.client.doJSON(ctx, "GET", u.String(), nil, &items) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + return &PagedResult[types.PullRequest]{ + Items: items, + TotalCount: totalCount, + Page: opts.Page, + HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= opts.Limit), + }, nil +} + +func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { + var all []types.PullRequest + page := 1 + + for { + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { + return func(yield func(types.PullRequest, error) bool) { + page := 1 + for { + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + if err != nil { + yield(*new(types.PullRequest), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + +// ListReviewComments returns all comments on a pull request review. +func (s *PullService) ListReviewComments(ctx context.Context, owner, repo string, index, reviewID int64) ([]types.PullReviewComment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) + return ListAll[types.PullReviewComment](ctx, s.client, path, nil) +} + +// IterReviewComments returns an iterator over all comments on a pull request review. +func (s *PullService) IterReviewComments(ctx context.Context, owner, repo string, index, reviewID int64) iter.Seq2[types.PullReviewComment, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) + return ListIter[types.PullReviewComment](ctx, s.client, path, nil) +} + +// GetReviewComment returns a single comment on a pull request review. +func (s *PullService) GetReviewComment(ctx context.Context, owner, repo string, index, reviewID, commentID int64) (*types.PullReviewComment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID), "comment", int64String(commentID))) + var out types.PullReviewComment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateReviewComment creates a new comment on a pull request review. +func (s *PullService) CreateReviewComment(ctx context.Context, owner, repo string, index, reviewID int64, opts *types.CreatePullReviewCommentOptions) (*types.PullReviewComment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) + var out types.PullReviewComment + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteReviewComment deletes a comment on a pull request review. +func (s *PullService) DeleteReviewComment(ctx context.Context, owner, repo string, index, reviewID, commentID int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID), "comment", int64String(commentID))) + return s.client.Delete(ctx, path) +} + // DismissReview dismisses a pull request review. func (s *PullService) DismissReview(ctx context.Context, owner, repo string, index, reviewID int64, msg string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, reviewID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) body := map[string]string{"message": msg} return s.client.Post(ctx, path, body, nil) } // UndismissReview undismisses a pull request review. func (s *PullService) UndismissReview(ctx context.Context, owner, repo string, index, reviewID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, reviewID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) return s.client.Post(ctx, path, nil, nil) } diff --git a/pulls_extra_test.go b/pulls_extra_test.go new file mode 100644 index 0000000..473b2f9 --- /dev/null +++ b/pulls_extra_test.go @@ -0,0 +1,67 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestPullService_ListPullRequests_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 1, Title: "add feature"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + prs, err := f.Pulls.ListPullRequests(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Title != "add feature" { + t.Fatalf("got %#v", prs) + } +} + +func TestPullService_CreatePullRequest_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreatePullRequestOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Title != "add feature" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.PullRequest{ID: 1, Title: body.Title, Index: 1}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Pulls.CreatePullRequest(context.Background(), "core", "go-forge", &types.CreatePullRequestOption{ + Title: "add feature", + Base: "main", + Head: "feature", + }) + if err != nil { + t.Fatal(err) + } + if pr.Title != "add feature" { + t.Fatalf("got title=%q", pr.Title) + } +} diff --git a/pulls_test.go b/pulls_test.go index cdc9512..7569d3f 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -2,19 +2,25 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" ) -func TestPullService_Good_List(t *testing.T) { +func TestPullService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } w.Header().Set("X-Total-Count", "2") json.NewEncoder(w).Encode([]types.PullRequest{ {ID: 1, Title: "add feature"}, @@ -36,7 +42,54 @@ func TestPullService_Good_List(t *testing.T) { } } -func TestPullService_Good_Get(t *testing.T) { +func TestPullService_ListFiltered_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "sort": "priority", + "milestone": "7", + "poster": "alice", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + if got := r.URL.Query()["labels"]; !reflect.DeepEqual(got, []string{"1", "2"}) { + t.Errorf("got labels=%v, want %v", got, []string{"1", "2"}) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 1, Title: "add feature"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + prs, err := f.Pulls.ListPullRequests(context.Background(), "core", "go-forge", PullListOptions{ + State: "open", + Sort: "priority", + Milestone: 7, + Labels: []int64{1, 2}, + Poster: "alice", + }) + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Title != "add feature" { + t.Fatalf("got %#v", prs) + } +} + +func TestPullService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -58,11 +111,16 @@ func TestPullService_Good_Get(t *testing.T) { } } -func TestPullService_Good_Create(t *testing.T) { +func TestPullService_Create_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } var body types.CreatePullRequestOption json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(http.StatusCreated) @@ -84,7 +142,248 @@ func TestPullService_Good_Create(t *testing.T) { } } -func TestPullService_Good_Merge(t *testing.T) { +func TestPullService_ListReviewers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/reviewers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.User{ + {UserName: "alice"}, + {UserName: "bob"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + reviewers, err := f.Pulls.ListReviewers(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(reviewers) != 2 || reviewers[0].UserName != "alice" || reviewers[1].UserName != "bob" { + t.Fatalf("got %#v", reviewers) + } +} + +func TestPullService_ListFiles_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/7/files" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.ChangedFile{ + {Filename: "README.md", Status: "modified", Additions: 2, Deletions: 1}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + files, err := f.Pulls.ListFiles(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatalf("got %d files, want 1", len(files)) + } + if files[0].Filename != "README.md" || files[0].Status != "modified" { + t.Fatalf("got %#v", files[0]) + } +} + +func TestPullService_GetByBaseHead_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/main/feature" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.PullRequest{Index: 7, Title: "Add feature"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Pulls.GetByBaseHead(context.Background(), "core", "go-forge", "main", "feature") + if err != nil { + t.Fatal(err) + } + if pr.Index != 7 || pr.Title != "Add feature" { + t.Fatalf("got %+v", pr) + } +} + +func TestPullService_IterFiles_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/7/files" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + switch requests { + case 1: + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.ChangedFile{{Filename: "README.md", Status: "modified"}}) + case 2: + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.ChangedFile{{Filename: "docs/guide.md", Status: "added"}}) + default: + t.Fatalf("unexpected request %d", requests) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for file, err := range f.Pulls.IterFiles(context.Background(), "core", "go-forge", 7) { + if err != nil { + t.Fatal(err) + } + got = append(got, file.Filename) + } + if len(got) != 2 || got[0] != "README.md" || got[1] != "docs/guide.md" { + t.Fatalf("got %#v", got) + } +} + +func TestPullService_IterReviewers_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/reviewers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + switch requests { + case 1: + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{{UserName: "alice"}}) + case 2: + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{{UserName: "bob"}}) + default: + t.Fatalf("unexpected request %d", requests) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for reviewer, err := range f.Pulls.IterReviewers(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, reviewer.UserName) + } + if len(got) != 2 || got[0] != "alice" || got[1] != "bob" { + t.Fatalf("got %#v", got) + } +} + +func TestPullService_RequestReviewers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/7/requested_reviewers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.PullReviewRequestOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Reviewers) != 2 || body.Reviewers[0] != "alice" || body.Reviewers[1] != "bob" { + t.Fatalf("got reviewers %#v", body.Reviewers) + } + if len(body.TeamReviewers) != 1 || body.TeamReviewers[0] != "platform" { + t.Fatalf("got team reviewers %#v", body.TeamReviewers) + } + json.NewEncoder(w).Encode([]types.PullReview{ + {ID: 101, Body: "requested"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + reviews, err := f.Pulls.RequestReviewers(context.Background(), "core", "go-forge", 7, &types.PullReviewRequestOptions{ + Reviewers: []string{"alice", "bob"}, + TeamReviewers: []string{"platform"}, + }) + if err != nil { + t.Fatal(err) + } + if len(reviews) != 1 || reviews[0].ID != 101 || reviews[0].Body != "requested" { + t.Fatalf("got %#v", reviews) + } +} + +func TestPullService_CancelReviewRequests_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/7/requested_reviewers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.PullReviewRequestOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Reviewers) != 1 || body.Reviewers[0] != "alice" { + t.Fatalf("got reviewers %#v", body.Reviewers) + } + if len(body.TeamReviewers) != 1 || body.TeamReviewers[0] != "platform" { + t.Fatalf("got team reviewers %#v", body.TeamReviewers) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Pulls.CancelReviewRequests(context.Background(), "core", "go-forge", 7, &types.PullReviewRequestOptions{ + Reviewers: []string{"alice"}, + TeamReviewers: []string{"platform"}, + }); err != nil { + t.Fatal(err) + } +} + +func TestPullService_Merge_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -107,3 +406,36 @@ func TestPullService_Good_Merge(t *testing.T) { t.Fatal(err) } } + +func TestPullService_Merge_Bad(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"message": "already merged"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Pulls.Merge(context.Background(), "core", "go-forge", 7, "merge"); !IsConflict(err) { + t.Fatalf("expected conflict, got %v", err) + } +} + +func TestPullService_CancelScheduledAutoMerge_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/7/merge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Pulls.CancelScheduledAutoMerge(context.Background(), "core", "go-forge", 7); err != nil { + t.Fatal(err) + } +} diff --git a/releases.go b/releases.go index ae32d49..906d259 100644 --- a/releases.go +++ b/releases.go @@ -2,17 +2,96 @@ package forge import ( "context" - "fmt" "iter" + "strconv" + + goio "io" "dappco.re/go/core/forge/types" ) // ReleaseService handles release operations within a repository. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Releases.ListAll(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type ReleaseService struct { Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption] } +// ReleaseListOptions controls filtering for repository release listings. +// +// Usage: +// +// opts := forge.ReleaseListOptions{Draft: true, Query: "1.0"} +type ReleaseListOptions struct { + Draft bool + PreRelease bool + Query string +} + +// String returns a safe summary of the release list filters. +func (o ReleaseListOptions) String() string { + return optionString("forge.ReleaseListOptions", + "draft", o.Draft, + "pre-release", o.PreRelease, + "q", o.Query, + ) +} + +// GoString returns a safe Go-syntax summary of the release list filters. +func (o ReleaseListOptions) GoString() string { return o.String() } + +func (o ReleaseListOptions) queryParams() map[string]string { + query := make(map[string]string, 3) + if o.Draft { + query["draft"] = strconv.FormatBool(true) + } + if o.PreRelease { + query["pre-release"] = strconv.FormatBool(true) + } + if o.Query != "" { + query["q"] = o.Query + } + if len(query) == 0 { + return nil + } + return query +} + +// ReleaseAttachmentUploadOptions controls metadata sent when uploading a release attachment. +// +// Usage: +// +// opts := forge.ReleaseAttachmentUploadOptions{Name: "release.zip"} +type ReleaseAttachmentUploadOptions struct { + Name string + ExternalURL string +} + +// String returns a safe summary of the release attachment upload metadata. +func (o ReleaseAttachmentUploadOptions) String() string { + return optionString("forge.ReleaseAttachmentUploadOptions", + "name", o.Name, + "external_url", o.ExternalURL, + ) +} + +// GoString returns a safe Go-syntax summary of the release attachment upload metadata. +func (o ReleaseAttachmentUploadOptions) GoString() string { return o.String() } + +func releaseAttachmentUploadQuery(opts *ReleaseAttachmentUploadOptions) map[string]string { + if opts == nil || opts.Name == "" { + return nil + } + query := make(map[string]string, 1) + if opts.Name != "" { + query["name"] = opts.Name + } + return query +} + func newReleaseService(c *Client) *ReleaseService { return &ReleaseService{ Resource: *NewResource[types.Release, types.CreateReleaseOption, types.EditReleaseOption]( @@ -21,9 +100,40 @@ func newReleaseService(c *Client) *ReleaseService { } } +// ListReleases returns all releases in a repository. +func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string, filters ...ReleaseListOptions) ([]types.Release, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Release](ctx, s.client, path, releaseListQuery(filters...)) +} + +// IterReleases returns an iterator over all releases in a repository. +func (s *ReleaseService) IterReleases(ctx context.Context, owner, repo string, filters ...ReleaseListOptions) iter.Seq2[types.Release, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Release](ctx, s.client, path, releaseListQuery(filters...)) +} + +// CreateRelease creates a release in a repository. +func (s *ReleaseService) CreateRelease(ctx context.Context, owner, repo string, opts *types.CreateReleaseOption) (*types.Release, error) { + var out types.Release + if err := s.client.Post(ctx, ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)), opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // GetByTag returns a release by its tag name. func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) (*types.Release, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) + var out types.Release + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetLatest returns the most recent non-prerelease, non-draft release. +func (s *ReleaseService) GetLatest(ctx context.Context, owner, repo string) (*types.Release, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/latest", pathParams("owner", owner, "repo", repo)) var out types.Release if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -33,25 +143,66 @@ func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) // DeleteByTag deletes a release by its tag name. func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) return s.client.Delete(ctx, path) } // ListAssets returns all assets for a release. func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, releaseID int64) ([]types.Attachment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(releaseID))) return ListAll[types.Attachment](ctx, s.client, path, nil) } +// CreateAttachment uploads a new attachment to a release. +// +// If opts.ExternalURL is set, the upload uses the external_url form field and +// ignores filename/content. +func (s *ReleaseService) CreateAttachment(ctx context.Context, owner, repo string, releaseID int64, opts *ReleaseAttachmentUploadOptions, filename string, content goio.Reader) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(releaseID))) + fields := make(map[string]string, 1) + fieldName := "attachment" + if opts != nil && opts.ExternalURL != "" { + fields["external_url"] = opts.ExternalURL + fieldName = "" + filename = "" + content = nil + } + var out types.Attachment + if err := s.client.postMultipartJSON(ctx, path, releaseAttachmentUploadQuery(opts), fields, fieldName, filename, content, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditAttachment updates a release attachment. +func (s *ReleaseService) EditAttachment(ctx context.Context, owner, repo string, releaseID, attachmentID int64, opts *types.EditAttachmentOptions) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{id}/assets/{attachment_id}", pathParams("owner", owner, "repo", repo, "id", int64String(releaseID), "attachment_id", int64String(attachmentID))) + var out types.Attachment + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateAsset uploads a new asset to a release. +func (s *ReleaseService) CreateAsset(ctx context.Context, owner, repo string, releaseID int64, opts *ReleaseAttachmentUploadOptions, filename string, content goio.Reader) (*types.Attachment, error) { + return s.CreateAttachment(ctx, owner, repo, releaseID, opts, filename, content) +} + +// EditAsset updates a release asset. +func (s *ReleaseService) EditAsset(ctx context.Context, owner, repo string, releaseID, attachmentID int64, opts *types.EditAttachmentOptions) (*types.Attachment, error) { + return s.EditAttachment(ctx, owner, repo, releaseID, attachmentID, opts) +} + // IterAssets returns an iterator over all assets for a release. func (s *ReleaseService) IterAssets(ctx context.Context, owner, repo string, releaseID int64) iter.Seq2[types.Attachment, error] { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{id}/assets", pathParams("owner", owner, "repo", repo, "id", int64String(releaseID))) return ListIter[types.Attachment](ctx, s.client, path, nil) } // GetAsset returns a single asset for a release. func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) (*types.Attachment, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets/{assetID}", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID), "assetID", int64String(assetID))) var out types.Attachment if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -61,6 +212,19 @@ func (s *ReleaseService) GetAsset(ctx context.Context, owner, repo string, relea // DeleteAsset deletes a single asset from a release. func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, releaseID, assetID int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets/{assetID}", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID), "assetID", int64String(assetID))) return s.client.Delete(ctx, path) } + +func releaseListQuery(filters ...ReleaseListOptions) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/releases_extra_test.go b/releases_extra_test.go new file mode 100644 index 0000000..9b58159 --- /dev/null +++ b/releases_extra_test.go @@ -0,0 +1,66 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestReleaseService_ListReleases_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Release{{ID: 1, TagName: "v1.0.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + releases, err := f.Releases.ListReleases(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(releases) != 1 || releases[0].TagName != "v1.0.0" { + t.Fatalf("got %#v", releases) + } +} + +func TestReleaseService_CreateRelease_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateReleaseOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.TagName != "v1.0.0" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.Release{ID: 1, TagName: body.TagName}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + release, err := f.Releases.CreateRelease(context.Background(), "core", "go-forge", &types.CreateReleaseOption{ + TagName: "v1.0.0", + Title: "Release 1.0", + }) + if err != nil { + t.Fatal(err) + } + if release.TagName != "v1.0.0" { + t.Fatalf("got tag=%q", release.TagName) + } +} diff --git a/releases_test.go b/releases_test.go index 1520fd2..8d5ca7d 100644 --- a/releases_test.go +++ b/releases_test.go @@ -1,16 +1,64 @@ package forge import ( + "bytes" "context" - "encoding/json" + json "github.com/goccy/go-json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" ) -func TestReleaseService_Good_List(t *testing.T) { +func readMultipartReleaseAttachment(t *testing.T, r *http.Request) (map[string]string, string, string) { + t.Helper() + + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatal(err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("got content-type=%q", mediaType) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + fields := make(map[string]string) + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + var fileName string + var fileContent string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(part) + if err != nil { + t.Fatal(err) + } + if part.FormName() == "attachment" { + fileName = part.FileName() + fileContent = string(data) + continue + } + fields[part.FormName()] = string(data) + } + + return fields, fileName, fileContent +} + +func TestReleaseService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -36,7 +84,48 @@ func TestReleaseService_Good_List(t *testing.T) { } } -func TestReleaseService_Good_Get(t *testing.T) { +func TestReleaseService_ListFiltered_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + want := map[string]string{ + "draft": "true", + "pre-release": "true", + "q": "1.0", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Release{{ID: 1, TagName: "v1.0.0", Title: "Release 1.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + releases, err := f.Releases.ListReleases(context.Background(), "core", "go-forge", ReleaseListOptions{ + Draft: true, + PreRelease: true, + Query: "1.0", + }) + if err != nil { + t.Fatal(err) + } + if len(releases) != 1 || releases[0].TagName != "v1.0.0" { + t.Fatalf("got %#v", releases) + } +} + +func TestReleaseService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -61,7 +150,7 @@ func TestReleaseService_Good_Get(t *testing.T) { } } -func TestReleaseService_Good_GetByTag(t *testing.T) { +func TestReleaseService_GetByTag_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -85,3 +174,146 @@ func TestReleaseService_Good_GetByTag(t *testing.T) { t.Errorf("got id=%d, want 1", release.ID) } } + +func TestReleaseService_GetLatest_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases/latest" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Release{ID: 3, TagName: "v2.1.0", Title: "Latest Release"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + release, err := f.Releases.GetLatest(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if release.TagName != "v2.1.0" { + t.Errorf("got tag=%q, want %q", release.TagName, "v2.1.0") + } + if release.Title != "Latest Release" { + t.Errorf("got title=%q, want %q", release.Title, "Latest Release") + } +} + +func TestReleaseService_CreateAttachment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases/1/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("name"); got != "linux-amd64" { + t.Fatalf("got name=%q", got) + } + fields, filename, content := readMultipartReleaseAttachment(t, r) + if !reflect.DeepEqual(fields, map[string]string{}) { + t.Fatalf("got fields=%#v", fields) + } + if filename != "release.tar.gz" { + t.Fatalf("got filename=%q", filename) + } + if content != "release bytes" { + t.Fatalf("got content=%q", content) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Attachment{ID: 9, Name: filename}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Releases.CreateAttachment( + context.Background(), + "core", + "go-forge", + 1, + &ReleaseAttachmentUploadOptions{Name: "linux-amd64"}, + "release.tar.gz", + bytes.NewBufferString("release bytes"), + ) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "release.tar.gz" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestReleaseService_CreateAttachmentExternalURL_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases/1/assets" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("name"); got != "docs" { + t.Fatalf("got name=%q", got) + } + fields, filename, content := readMultipartReleaseAttachment(t, r) + if !reflect.DeepEqual(fields, map[string]string{"external_url": "https://example.com/release.tar.gz"}) { + t.Fatalf("got fields=%#v", fields) + } + if filename != "" || content != "" { + t.Fatalf("unexpected file upload: filename=%q content=%q", filename, content) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.Attachment{ID: 10, Name: "docs"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Releases.CreateAttachment( + context.Background(), + "core", + "go-forge", + 1, + &ReleaseAttachmentUploadOptions{Name: "docs", ExternalURL: "https://example.com/release.tar.gz"}, + "", + nil, + ) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "docs" { + t.Fatalf("got name=%q", attachment.Name) + } +} + +func TestReleaseService_EditAttachment_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases/1/assets/4" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditAttachmentOptions + json.NewDecoder(r.Body).Decode(&body) + if body.Name != "release-notes.pdf" { + t.Fatalf("got body=%#v", body) + } + json.NewEncoder(w).Encode(types.Attachment{ID: 4, Name: body.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachment, err := f.Releases.EditAttachment(context.Background(), "core", "go-forge", 1, 4, &types.EditAttachmentOptions{Name: "release-notes.pdf"}) + if err != nil { + t.Fatal(err) + } + if attachment.Name != "release-notes.pdf" { + t.Fatalf("got name=%q", attachment.Name) + } +} diff --git a/repos.go b/repos.go index 13a9f0b..8b826a8 100644 --- a/repos.go +++ b/repos.go @@ -3,15 +3,123 @@ package forge import ( "context" "iter" + "net/http" + "net/url" + "strconv" + "time" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // RepoService handles repository operations. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Repos.ListOrgRepos(ctx, "core") type RepoService struct { Resource[types.Repository, types.CreateRepoOption, types.EditRepoOption] } +// RepoKeyListOptions controls filtering for repository key listings. +// +// Usage: +// +// opts := forge.RepoKeyListOptions{Fingerprint: "AB:CD"} +type RepoKeyListOptions struct { + KeyID int64 + Fingerprint string +} + +// String returns a safe summary of the repository key filters. +func (o RepoKeyListOptions) String() string { + return optionString("forge.RepoKeyListOptions", "key_id", o.KeyID, "fingerprint", o.Fingerprint) +} + +// GoString returns a safe Go-syntax summary of the repository key filters. +func (o RepoKeyListOptions) GoString() string { return o.String() } + +func (o RepoKeyListOptions) queryParams() map[string]string { + query := make(map[string]string, 2) + if o.KeyID != 0 { + query["key_id"] = strconv.FormatInt(o.KeyID, 10) + } + if o.Fingerprint != "" { + query["fingerprint"] = o.Fingerprint + } + if len(query) == 0 { + return nil + } + return query +} + +// ActivityFeedListOptions controls filtering for repository activity feeds. +// +// Usage: +// +// opts := forge.ActivityFeedListOptions{Date: &day} +type ActivityFeedListOptions struct { + Date *time.Time +} + +// String returns a safe summary of the activity feed filters. +func (o ActivityFeedListOptions) String() string { + return optionString("forge.ActivityFeedListOptions", "date", o.Date) +} + +// GoString returns a safe Go-syntax summary of the activity feed filters. +func (o ActivityFeedListOptions) GoString() string { return o.String() } + +func (o ActivityFeedListOptions) queryParams() map[string]string { + if o.Date == nil { + return nil + } + return map[string]string{ + "date": o.Date.Format("2006-01-02"), + } +} + +// RepoTimeListOptions controls filtering for repository tracked times. +// +// Usage: +// +// opts := forge.RepoTimeListOptions{User: "alice"} +type RepoTimeListOptions struct { + User string + Since *time.Time + Before *time.Time +} + +// String returns a safe summary of the tracked time filters. +func (o RepoTimeListOptions) String() string { + return optionString("forge.RepoTimeListOptions", + "user", o.User, + "since", o.Since, + "before", o.Before, + ) +} + +// GoString returns a safe Go-syntax summary of the tracked time filters. +func (o RepoTimeListOptions) GoString() string { return o.String() } + +func (o RepoTimeListOptions) queryParams() map[string]string { + query := make(map[string]string, 3) + if o.User != "" { + query["user"] = o.User + } + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if len(query) == 0 { + return nil + } + return query +} + func newRepoService(c *Client) *RepoService { return &RepoService{ Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption]( @@ -20,14 +128,54 @@ func newRepoService(c *Client) *RepoService { } } +// Migrate imports a remote git repository into Forgejo. +func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOptions) (*types.Repository, error) { + var out types.Repository + if err := s.client.Post(ctx, "/api/v1/repos/migrate", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateCurrentUserRepo creates a repository for the authenticated user. +func (s *RepoService) CreateCurrentUserRepo(ctx context.Context, opts *types.CreateRepoOption) (*types.Repository, error) { + var out types.Repository + if err := s.client.Post(ctx, "/api/v1/user/repos", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgRepo creates a repository in an organisation. +func (s *RepoService) CreateOrgRepo(ctx context.Context, org string, opts *types.CreateRepoOption) (*types.Repository, error) { + path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) + var out types.Repository + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgRepoDeprecated creates a repository in an organisation using the deprecated route. +func (s *RepoService) CreateOrgRepoDeprecated(ctx context.Context, org string, opts *types.CreateRepoOption) (*types.Repository, error) { + path := ResolvePath("/api/v1/org/{org}/repos", pathParams("org", org)) + var out types.Repository + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { - return ListAll[types.Repository](ctx, s.client, "/api/v1/orgs/"+org+"/repos", nil) + path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) + return ListAll[types.Repository](ctx, s.client, path, nil) } // IterOrgRepos returns an iterator over all repositories for an organisation. func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[types.Repository, error] { - return ListIter[types.Repository](ctx, s.client, "/api/v1/orgs/"+org+"/repos", nil) + path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) + return ListIter[types.Repository](ctx, s.client, path, nil) } // ListUserRepos returns all repositories for the authenticated user. @@ -40,36 +188,905 @@ func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Reposit return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } -// Fork forks a repository. If org is non-empty, forks into that organisation. +// GetByID returns a repository by its numeric ID. +func (s *RepoService) GetByID(ctx context.Context, id int64) (*types.Repository, error) { + path := ResolvePath("/api/v1/repositories/{id}", pathParams("id", int64String(id))) + var out types.Repository + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTags returns all tags for a repository. +func (s *RepoService) ListTags(ctx context.Context, owner, repo string) ([]types.Tag, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tags", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Tag](ctx, s.client, path, nil) +} + +// IterTags returns an iterator over all tags for a repository. +func (s *RepoService) IterTags(ctx context.Context, owner, repo string) iter.Seq2[types.Tag, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tags", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Tag](ctx, s.client, path, nil) +} + +// GetTag returns a single tag by name. +func (s *RepoService) GetTag(ctx context.Context, owner, repo, tag string) (*types.Tag, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) + var out types.Tag + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteTag deletes a repository tag by name. +func (s *RepoService) DeleteTag(ctx context.Context, owner, repo, tag string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) + return s.client.Delete(ctx, path) +} + +// ListTagProtections returns all tag protections for a repository. +func (s *RepoService) ListTagProtections(ctx context.Context, owner, repo string) ([]types.TagProtection, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections", pathParams("owner", owner, "repo", repo)) + return ListAll[types.TagProtection](ctx, s.client, path, nil) +} + +// IterTagProtections returns an iterator over all tag protections for a repository. +func (s *RepoService) IterTagProtections(ctx context.Context, owner, repo string) iter.Seq2[types.TagProtection, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections", pathParams("owner", owner, "repo", repo)) + return ListIter[types.TagProtection](ctx, s.client, path, nil) +} + +// GetTagProtection returns a single tag protection by ID. +func (s *RepoService) GetTagProtection(ctx context.Context, owner, repo string, id int64) (*types.TagProtection, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.TagProtection + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateTagProtection creates a new tag protection for a repository. +func (s *RepoService) CreateTagProtection(ctx context.Context, owner, repo string, opts *types.CreateTagProtectionOption) (*types.TagProtection, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections", pathParams("owner", owner, "repo", repo)) + var out types.TagProtection + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditTagProtection updates an existing tag protection for a repository. +func (s *RepoService) EditTagProtection(ctx context.Context, owner, repo string, id int64, opts *types.EditTagProtectionOption) (*types.TagProtection, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.TagProtection + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteTagProtection deletes a tag protection from a repository. +func (s *RepoService) DeleteTagProtection(ctx context.Context, owner, repo string, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/tag_protections/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListKeys returns all deploy keys for a repository. +func (s *RepoService) ListKeys(ctx context.Context, owner, repo string, filters ...RepoKeyListOptions) ([]types.DeployKey, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo)) + return ListAll[types.DeployKey](ctx, s.client, path, repoKeyQuery(filters...)) +} + +// IterKeys returns an iterator over all deploy keys for a repository. +func (s *RepoService) IterKeys(ctx context.Context, owner, repo string, filters ...RepoKeyListOptions) iter.Seq2[types.DeployKey, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo)) + return ListIter[types.DeployKey](ctx, s.client, path, repoKeyQuery(filters...)) +} + +// GetKey returns a single deploy key by ID. +func (s *RepoService) GetKey(ctx context.Context, owner, repo string, id int64) (*types.DeployKey, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + var out types.DeployKey + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateKey adds a deploy key to a repository. +func (s *RepoService) CreateKey(ctx context.Context, owner, repo string, opts *types.CreateKeyOption) (*types.DeployKey, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys", pathParams("owner", owner, "repo", repo)) + var out types.DeployKey + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteKey removes a deploy key from a repository by ID. +func (s *RepoService) DeleteKey(ctx context.Context, owner, repo string, id int64) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/keys/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListStargazers returns all users who starred a repository. +func (s *RepoService) ListStargazers(ctx context.Context, owner, repo string) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/stargazers", pathParams("owner", owner, "repo", repo)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterStargazers returns an iterator over all users who starred a repository. +func (s *RepoService) IterStargazers(ctx context.Context, owner, repo string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/stargazers", pathParams("owner", owner, "repo", repo)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// ListSubscribers returns all users watching a repository. +func (s *RepoService) ListSubscribers(ctx context.Context, owner, repo string) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/subscribers", pathParams("owner", owner, "repo", repo)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterSubscribers returns an iterator over all users watching a repository. +func (s *RepoService) IterSubscribers(ctx context.Context, owner, repo string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/subscribers", pathParams("owner", owner, "repo", repo)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// ListAssignees returns all users that can be assigned to issues in a repository. +func (s *RepoService) ListAssignees(ctx context.Context, owner, repo string) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/assignees", pathParams("owner", owner, "repo", repo)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterAssignees returns an iterator over all users that can be assigned to issues in a repository. +func (s *RepoService) IterAssignees(ctx context.Context, owner, repo string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/assignees", pathParams("owner", owner, "repo", repo)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// ListCollaborators returns all collaborators on a repository. +func (s *RepoService) ListCollaborators(ctx context.Context, owner, repo string) ([]types.User, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators", pathParams("owner", owner, "repo", repo)) + return ListAll[types.User](ctx, s.client, path, nil) +} + +// IterCollaborators returns an iterator over all collaborators on a repository. +func (s *RepoService) IterCollaborators(ctx context.Context, owner, repo string) iter.Seq2[types.User, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators", pathParams("owner", owner, "repo", repo)) + return ListIter[types.User](ctx, s.client, path, nil) +} + +// ListRepoTeams returns all teams assigned to a repository. +func (s *RepoService) ListRepoTeams(ctx context.Context, owner, repo string) ([]types.Team, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/teams", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Team](ctx, s.client, path, nil) +} + +// IterRepoTeams returns an iterator over all teams assigned to a repository. +func (s *RepoService) IterRepoTeams(ctx context.Context, owner, repo string) iter.Seq2[types.Team, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/teams", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Team](ctx, s.client, path, nil) +} + +// GetRepoTeam returns a team assigned to a repository by name. +func (s *RepoService) GetRepoTeam(ctx context.Context, owner, repo, team string) (*types.Team, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/teams/{team}", pathParams("owner", owner, "repo", repo, "team", team)) + var out types.Team + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// AddRepoTeam assigns a team to a repository. +func (s *RepoService) AddRepoTeam(ctx context.Context, owner, repo, team string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/teams/{team}", pathParams("owner", owner, "repo", repo, "team", team)) + return s.client.Put(ctx, path, nil, nil) +} + +// DeleteRepoTeam removes a team from a repository. +func (s *RepoService) DeleteRepoTeam(ctx context.Context, owner, repo, team string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/teams/{team}", pathParams("owner", owner, "repo", repo, "team", team)) + return s.client.Delete(ctx, path) +} + +// CheckCollaborator reports whether a user is a collaborator on a repository. +func (s *RepoService) CheckCollaborator(ctx context.Context, owner, repo, collaborator string) (bool, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators/{collaborator}", pathParams("owner", owner, "repo", repo, "collaborator", collaborator)) + resp, err := s.client.doJSON(ctx, "GET", path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == 204, nil +} + +// AddCollaborator adds a user as a collaborator on a repository. +func (s *RepoService) AddCollaborator(ctx context.Context, owner, repo, collaborator string, opts *types.AddCollaboratorOption) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators/{collaborator}", pathParams("owner", owner, "repo", repo, "collaborator", collaborator)) + return s.client.Put(ctx, path, opts, nil) +} + +// DeleteCollaborator removes a user from a repository's collaborators. +func (s *RepoService) DeleteCollaborator(ctx context.Context, owner, repo, collaborator string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators/{collaborator}", pathParams("owner", owner, "repo", repo, "collaborator", collaborator)) + return s.client.Delete(ctx, path) +} + +// GetCollaboratorPermission returns repository permissions for a collaborator. +func (s *RepoService) GetCollaboratorPermission(ctx context.Context, owner, repo, collaborator string) (*types.RepoCollaboratorPermission, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators/{collaborator}/permission", pathParams("owner", owner, "repo", repo, "collaborator", collaborator)) + var out types.RepoCollaboratorPermission + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetRepoPermissions returns repository permissions for a user. +func (s *RepoService) GetRepoPermissions(ctx context.Context, owner, repo, collaborator string) (*types.RepoCollaboratorPermission, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/collaborators/{collaborator}/permission", pathParams("owner", owner, "repo", repo, "collaborator", collaborator)) + var out types.RepoCollaboratorPermission + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetArchive returns a repository archive as raw bytes. +func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive string) ([]byte, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/archive/{archive}", pathParams("owner", owner, "repo", repo, "archive", archive)) + return s.client.GetRaw(ctx, path) +} + +// Compare returns commit comparison information between two branches or commits. +func (s *RepoService) Compare(ctx context.Context, owner, repo, basehead string) (*types.Compare, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/compare/{basehead}", pathParams("owner", owner, "repo", repo, "basehead", basehead)) + var out types.Compare + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetRawFile returns the raw content of a repository file as bytes. +func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath string) ([]byte, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/raw/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) + return s.client.GetRaw(ctx, path) +} + +// GetRawFileOrLFS returns the raw content or LFS object for a repository file as bytes. +func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/media/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) + if ref != "" { + u, err := url.Parse(path) + if err != nil { + return nil, core.E("RepoService.GetRawFileOrLFS", "forge: parse path", err) + } + q := u.Query() + q.Set("ref", ref) + u.RawQuery = q.Encode() + path = u.String() + } + return s.client.GetRaw(ctx, path) +} + +// GetEditorConfig returns the EditorConfig definitions for a repository file. +func (s *RepoService) GetEditorConfig(ctx context.Context, owner, repo, filepath, ref string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/editorconfig/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) + if ref != "" { + u, err := url.Parse(path) + if err != nil { + return core.E("RepoService.GetEditorConfig", "forge: parse path", err) + } + q := u.Query() + q.Set("ref", ref) + u.RawQuery = q.Encode() + path = u.String() + } + return s.client.Get(ctx, path, nil) +} + +// ApplyDiffPatch applies a diff patch to a repository. +func (s *RepoService) ApplyDiffPatch(ctx context.Context, owner, repo string, opts *types.UpdateFileOptions) (*types.FileResponse, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/diffpatch", pathParams("owner", owner, "repo", repo)) + var out types.FileResponse + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetLanguages returns the byte counts per language for a repository. +func (s *RepoService) GetLanguages(ctx context.Context, owner, repo string) (map[string]int64, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/languages", pathParams("owner", owner, "repo", repo)) + var out map[string]int64 + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + +// ListFlags returns all flags for a repository. +func (s *RepoService) ListFlags(ctx context.Context, owner, repo string) ([]string, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags", pathParams("owner", owner, "repo", repo)) + var out []string + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + +// IterFlags returns an iterator over all flags for a repository. +func (s *RepoService) IterFlags(ctx context.Context, owner, repo string) iter.Seq2[string, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags", pathParams("owner", owner, "repo", repo)) + return ListIter[string](ctx, s.client, path, nil) +} + +// ReplaceFlags replaces all flags for a repository. +func (s *RepoService) ReplaceFlags(ctx context.Context, owner, repo string, opts *types.ReplaceFlagsOption) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags", pathParams("owner", owner, "repo", repo)) + return s.client.Put(ctx, path, opts, nil) +} + +// DeleteFlags removes all flags from a repository. +func (s *RepoService) DeleteFlags(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags", pathParams("owner", owner, "repo", repo)) + return s.client.Delete(ctx, path) +} + +// GetSigningKey returns the repository signing key as ASCII-armoured text. +func (s *RepoService) GetSigningKey(ctx context.Context, owner, repo string) (string, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/signing-key.gpg", pathParams("owner", owner, "repo", repo)) + data, err := s.client.GetRaw(ctx, path) + if err != nil { + return "", err + } + return string(data), nil +} + +// ListIssueTemplates returns all issue templates available for a repository. +func (s *RepoService) ListIssueTemplates(ctx context.Context, owner, repo string) ([]types.IssueTemplate, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issue_templates", pathParams("owner", owner, "repo", repo)) + return ListAll[types.IssueTemplate](ctx, s.client, path, nil) +} + +// IterIssueTemplates returns an iterator over all issue templates available for a repository. +func (s *RepoService) IterIssueTemplates(ctx context.Context, owner, repo string) iter.Seq2[types.IssueTemplate, error] { + return func(yield func(types.IssueTemplate, error) bool) { + templates, err := s.ListIssueTemplates(ctx, owner, repo) + if err != nil { + yield(*new(types.IssueTemplate), err) + return + } + for _, template := range templates { + if !yield(template, nil) { + return + } + } + } +} + +// GetIssueConfig returns the issue config for a repository. +func (s *RepoService) GetIssueConfig(ctx context.Context, owner, repo string) (*types.IssueConfig, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issue_config", pathParams("owner", owner, "repo", repo)) + var out types.IssueConfig + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ValidateIssueConfig returns the validation information for a repository's issue config. +func (s *RepoService) ValidateIssueConfig(ctx context.Context, owner, repo string) (*types.IssueConfigValidation, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issue_config/validate", pathParams("owner", owner, "repo", repo)) + var out types.IssueConfigValidation + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListActivityFeeds returns the repository's activity feed entries. +func (s *RepoService) ListActivityFeeds(ctx context.Context, owner, repo string, filters ...ActivityFeedListOptions) ([]types.Activity, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/activities/feeds", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Activity](ctx, s.client, path, activityFeedQuery(filters...)) +} + +// IterActivityFeeds returns an iterator over the repository's activity feed entries. +func (s *RepoService) IterActivityFeeds(ctx context.Context, owner, repo string, filters ...ActivityFeedListOptions) iter.Seq2[types.Activity, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/activities/feeds", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Activity](ctx, s.client, path, activityFeedQuery(filters...)) +} + +// ListTopics returns the topics assigned to a repository. +func (s *RepoService) ListTopics(ctx context.Context, owner, repo string) ([]string, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/topics", pathParams("owner", owner, "repo", repo)) + var out types.TopicName + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out.TopicNames, nil +} + +// IterTopics returns an iterator over the topics assigned to a repository. +func (s *RepoService) IterTopics(ctx context.Context, owner, repo string) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + topics, err := s.ListTopics(ctx, owner, repo) + if err != nil { + yield("", err) + return + } + for _, topic := range topics { + if !yield(topic, nil) { + return + } + } + } +} + +// SearchTopics searches topics by keyword. +func (s *RepoService) SearchTopics(ctx context.Context, query string) ([]types.TopicResponse, error) { + return ListAll[types.TopicResponse](ctx, s.client, "/api/v1/topics/search", map[string]string{"q": query}) +} + +// IterSearchTopics returns an iterator over topic search results. +func (s *RepoService) IterSearchTopics(ctx context.Context, query string) iter.Seq2[types.TopicResponse, error] { + return ListIter[types.TopicResponse](ctx, s.client, "/api/v1/topics/search", map[string]string{"q": query}) +} + +// SearchRepositoriesPage returns a single page of repository search results. +func (s *RepoService) SearchRepositoriesPage(ctx context.Context, query string, pageOpts ListOptions) (*PagedResult[types.Repository], error) { + if pageOpts.Page < 1 { + pageOpts.Page = 1 + } + if pageOpts.Limit < 1 { + pageOpts.Limit = 50 + } + + u, err := url.Parse("/api/v1/repos/search") + if err != nil { + return nil, core.E("RepoService.SearchRepositoriesPage", "forge: parse path", err) + } + + q := u.Query() + q.Set("q", query) + q.Set("page", strconv.Itoa(pageOpts.Page)) + q.Set("limit", strconv.Itoa(pageOpts.Limit)) + u.RawQuery = q.Encode() + + var out types.SearchResults + resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + items := make([]types.Repository, 0, len(out.Data)) + for _, repo := range out.Data { + if repo != nil { + items = append(items, *repo) + } + } + + return &PagedResult[types.Repository]{ + Items: items, + TotalCount: totalCount, + Page: pageOpts.Page, + HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageOpts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageOpts.Limit), + }, nil +} + +// SearchRepositories returns all repositories matching the search query. +func (s *RepoService) SearchRepositories(ctx context.Context, query string) ([]types.Repository, error) { + var all []types.Repository + page := 1 + + for { + result, err := s.SearchRepositoriesPage(ctx, query, ListOptions{Page: page, Limit: 50}) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +// IterSearchRepositories returns an iterator over all repositories matching the search query. +func (s *RepoService) IterSearchRepositories(ctx context.Context, query string) iter.Seq2[types.Repository, error] { + return func(yield func(types.Repository, error) bool) { + page := 1 + for { + result, err := s.SearchRepositoriesPage(ctx, query, ListOptions{Page: page, Limit: 50}) + if err != nil { + yield(*new(types.Repository), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + +// UpdateTopics replaces the topics assigned to a repository. +func (s *RepoService) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/topics", pathParams("owner", owner, "repo", repo)) + return s.client.Put(ctx, path, types.RepoTopicOptions{Topics: topics}, nil) +} + +// AddTopic adds a topic to a repository. +func (s *RepoService) AddTopic(ctx context.Context, owner, repo, topic string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/topics/{topic}", pathParams("owner", owner, "repo", repo, "topic", topic)) + return s.client.Put(ctx, path, nil, nil) +} + +// DeleteTopic removes a topic from a repository. +func (s *RepoService) DeleteTopic(ctx context.Context, owner, repo, topic string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/topics/{topic}", pathParams("owner", owner, "repo", repo, "topic", topic)) + return s.client.Delete(ctx, path) +} + +// AddFlag adds a flag to a repository. +func (s *RepoService) AddFlag(ctx context.Context, owner, repo, flag string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags/{flag}", pathParams("owner", owner, "repo", repo, "flag", flag)) + return s.client.Put(ctx, path, nil, nil) +} + +// HasFlag reports whether a repository has a given flag. +func (s *RepoService) HasFlag(ctx context.Context, owner, repo, flag string) (bool, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags/{flag}", pathParams("owner", owner, "repo", repo, "flag", flag)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} + +// RemoveFlag removes a flag from a repository. +func (s *RepoService) RemoveFlag(ctx context.Context, owner, repo, flag string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/flags/{flag}", pathParams("owner", owner, "repo", repo, "flag", flag)) + return s.client.Delete(ctx, path) +} + +// GetNewPinAllowed returns whether new issue pins are allowed for a repository. +func (s *RepoService) GetNewPinAllowed(ctx context.Context, owner, repo string) (*types.NewIssuePinsAllowed, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/new_pin_allowed", pathParams("owner", owner, "repo", repo)) + var out types.NewIssuePinsAllowed + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListPinnedPullRequests returns all pinned pull requests in a repository. +func (s *RepoService) ListPinnedPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/pinned", pathParams("owner", owner, "repo", repo)) + return ListAll[types.PullRequest](ctx, s.client, path, nil) +} + +// IterPinnedPullRequests returns an iterator over all pinned pull requests in a repository. +func (s *RepoService) IterPinnedPullRequests(ctx context.Context, owner, repo string) iter.Seq2[types.PullRequest, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/pinned", pathParams("owner", owner, "repo", repo)) + return ListIter[types.PullRequest](ctx, s.client, path, nil) +} + +// UpdateAvatar updates a repository avatar. +func (s *RepoService) UpdateAvatar(ctx context.Context, owner, repo string, opts *types.UpdateRepoAvatarOption) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/avatar", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, opts, nil) +} + +// DeleteAvatar deletes a repository avatar. +func (s *RepoService) DeleteAvatar(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/avatar", pathParams("owner", owner, "repo", repo)) + return s.client.Delete(ctx, path) +} + +// ListPushMirrors returns all push mirrors configured for a repository. +func (s *RepoService) ListPushMirrors(ctx context.Context, owner, repo string) ([]types.PushMirror, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors", pathParams("owner", owner, "repo", repo)) + return ListAll[types.PushMirror](ctx, s.client, path, nil) +} + +// IterPushMirrors returns an iterator over all push mirrors configured for a repository. +func (s *RepoService) IterPushMirrors(ctx context.Context, owner, repo string) iter.Seq2[types.PushMirror, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors", pathParams("owner", owner, "repo", repo)) + return ListIter[types.PushMirror](ctx, s.client, path, nil) +} + +// GetPushMirror returns a push mirror by its remote name. +func (s *RepoService) GetPushMirror(ctx context.Context, owner, repo, name string) (*types.PushMirror, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors/{name}", pathParams("owner", owner, "repo", repo, "name", name)) + var out types.PushMirror + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreatePushMirror adds a push mirror to a repository. +func (s *RepoService) CreatePushMirror(ctx context.Context, owner, repo string, opts *types.CreatePushMirrorOption) (*types.PushMirror, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors", pathParams("owner", owner, "repo", repo)) + var out types.PushMirror + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeletePushMirror removes a push mirror from a repository by remote name. +func (s *RepoService) DeletePushMirror(ctx context.Context, owner, repo, name string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors/{name}", pathParams("owner", owner, "repo", repo, "name", name)) + return s.client.Delete(ctx, path) +} + +// GetSubscription returns the current user's watch state for a repository. +func (s *RepoService) GetSubscription(ctx context.Context, owner, repo string) (*types.WatchInfo, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/subscription", pathParams("owner", owner, "repo", repo)) + var out types.WatchInfo + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Watch subscribes the current user to repository notifications. +func (s *RepoService) Watch(ctx context.Context, owner, repo string) (*types.WatchInfo, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/subscription", pathParams("owner", owner, "repo", repo)) + var out types.WatchInfo + if err := s.client.Put(ctx, path, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Unwatch unsubscribes the current user from repository notifications. +func (s *RepoService) Unwatch(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/subscription", pathParams("owner", owner, "repo", repo)) + return s.client.Delete(ctx, path) +} + +// Fork forks a repository into the authenticated user's namespace or the +// optional organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { - body := map[string]string{} - if org != "" { - body["organization"] = org + opts := &types.CreateForkOption{Organization: org} + return s.ForkWithOptions(ctx, owner, repo, opts) +} + +// ForkWithOptions forks a repository with full control over the fork target. +func (s *RepoService) ForkWithOptions(ctx context.Context, owner, repo string, opts *types.CreateForkOption) (*types.Repository, error) { + var out types.Repository + path := ResolvePath("/api/v1/repos/{owner}/{repo}/forks", pathParams("owner", owner, "repo", repo)) + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err } + return &out, nil +} + +// Generate creates a repository from a template repository. +func (s *RepoService) Generate(ctx context.Context, templateOwner, templateRepo string, opts *types.GenerateRepoOption) (*types.Repository, error) { + path := ResolvePath("/api/v1/repos/{template_owner}/{template_repo}/generate", pathParams("template_owner", templateOwner, "template_repo", templateRepo)) var out types.Repository - err := s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/forks", body, &out) - if err != nil { + if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err } return &out, nil } +// ListForks returns all forks of a repository. +func (s *RepoService) ListForks(ctx context.Context, owner, repo string) ([]types.Repository, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/forks", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Repository](ctx, s.client, path, nil) +} + +// IterForks returns an iterator over all forks of a repository. +func (s *RepoService) IterForks(ctx context.Context, owner, repo string) iter.Seq2[types.Repository, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/forks", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Repository](ctx, s.client, path, nil) +} + // Transfer initiates a repository transfer. -func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts map[string]any) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer", opts, nil) +func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts *types.TransferRepoOption) (*types.Repository, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer", pathParams("owner", owner, "repo", repo)) + var out types.Repository + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil } // AcceptTransfer accepts a pending repository transfer. -func (s *RepoService) AcceptTransfer(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/accept", nil, nil) +func (s *RepoService) AcceptTransfer(ctx context.Context, owner, repo string) (*types.Repository, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer/accept", pathParams("owner", owner, "repo", repo)) + var out types.Repository + if err := s.client.Post(ctx, path, nil, &out); err != nil { + return nil, err + } + return &out, nil } // RejectTransfer rejects a pending repository transfer. -func (s *RepoService) RejectTransfer(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/transfer/reject", nil, nil) +func (s *RepoService) RejectTransfer(ctx context.Context, owner, repo string) (*types.Repository, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer/reject", pathParams("owner", owner, "repo", repo)) + var out types.Repository + if err := s.client.Post(ctx, path, nil, &out); err != nil { + return nil, err + } + return &out, nil } // MirrorSync triggers a mirror sync. func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error { - return s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/mirror-sync", nil, nil) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/mirror-sync", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, nil, nil) +} + +// GetRunnerRegistrationToken returns a repository actions runner registration token. +func (s *RepoService) GetRunnerRegistrationToken(ctx context.Context, owner, repo string) (string, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/runners/registration-token", pathParams("owner", owner, "repo", repo)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return "", err + } + return resp.Header.Get("token"), nil +} + +// SyncPushMirrors triggers a sync across all push mirrors configured for a repository. +func (s *RepoService) SyncPushMirrors(ctx context.Context, owner, repo string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors-sync", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, nil, nil) +} + +// GetBlob returns the blob content for a repository object. +func (s *RepoService) GetBlob(ctx context.Context, owner, repo, sha string) (*types.GitBlobResponse, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/blobs/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) + var out types.GitBlobResponse + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListGitRefs returns all git references for a repository. +func (s *RepoService) ListGitRefs(ctx context.Context, owner, repo string) ([]types.Reference, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/refs", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Reference](ctx, s.client, path, nil) +} + +// IterGitRefs returns an iterator over all git references for a repository. +func (s *RepoService) IterGitRefs(ctx context.Context, owner, repo string) iter.Seq2[types.Reference, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/refs", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Reference](ctx, s.client, path, nil) +} + +// ListGitRefsByRef returns all git references matching a ref prefix. +func (s *RepoService) ListGitRefsByRef(ctx context.Context, owner, repo, ref string) ([]types.Reference, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/refs/{ref}", pathParams("owner", owner, "repo", repo, "ref", ref)) + return ListAll[types.Reference](ctx, s.client, path, nil) +} + +// IterGitRefsByRef returns an iterator over all git references matching a ref prefix. +func (s *RepoService) IterGitRefsByRef(ctx context.Context, owner, repo, ref string) iter.Seq2[types.Reference, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/refs/{ref}", pathParams("owner", owner, "repo", repo, "ref", ref)) + return ListIter[types.Reference](ctx, s.client, path, nil) +} + +// GetAnnotatedTag returns the annotated tag object for a tag SHA. +func (s *RepoService) GetAnnotatedTag(ctx context.Context, owner, repo, sha string) (*types.AnnotatedTag, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/tags/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) + var out types.AnnotatedTag + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetTree returns the git tree for a repository object. +func (s *RepoService) GetTree(ctx context.Context, owner, repo, sha string) (*types.GitTreeResponse, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/trees/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) + var out types.GitTreeResponse + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTimes returns all tracked times for a repository. +func (s *RepoService) ListTimes(ctx context.Context, owner, repo string, filters ...RepoTimeListOptions) ([]types.TrackedTime, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/times", pathParams("owner", owner, "repo", repo)) + return ListAll[types.TrackedTime](ctx, s.client, path, repoTimeQuery(filters...)) +} + +// IterTimes returns an iterator over all tracked times for a repository. +func (s *RepoService) IterTimes(ctx context.Context, owner, repo string, filters ...RepoTimeListOptions) iter.Seq2[types.TrackedTime, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/times", pathParams("owner", owner, "repo", repo)) + return ListIter[types.TrackedTime](ctx, s.client, path, repoTimeQuery(filters...)) +} + +// ListUserTimes returns all tracked times for a user in a repository. +func (s *RepoService) ListUserTimes(ctx context.Context, owner, repo, username string) ([]types.TrackedTime, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/times/{user}", pathParams("owner", owner, "repo", repo, "user", username)) + return ListAll[types.TrackedTime](ctx, s.client, path, nil) +} + +// IterUserTimes returns an iterator over all tracked times for a user in a repository. +func (s *RepoService) IterUserTimes(ctx context.Context, owner, repo, username string) iter.Seq2[types.TrackedTime, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/times/{user}", pathParams("owner", owner, "repo", repo, "user", username)) + return ListIter[types.TrackedTime](ctx, s.client, path, nil) +} + +func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 2) + for _, filter := range filters { + if filter.KeyID != 0 { + query["key_id"] = strconv.FormatInt(filter.KeyID, 10) + } + if filter.Fingerprint != "" { + query["fingerprint"] = filter.Fingerprint + } + } + if len(query) == 0 { + return nil + } + return query +} + +func repoTimeQuery(filters ...RepoTimeListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 3) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} + +func activityFeedQuery(filters ...ActivityFeedListOptions) map[string]string { + if len(filters) == 0 { + return nil + } + + query := make(map[string]string, 1) + for _, filter := range filters { + if filter.Date != nil { + query["date"] = filter.Date.Format("2006-01-02") + } + } + if len(query) == 0 { + return nil + } + return query } diff --git a/repos_test.go b/repos_test.go new file mode 100644 index 0000000..3c6c22b --- /dev/null +++ b/repos_test.go @@ -0,0 +1,2277 @@ +package forge + +import ( + "bytes" + "context" + json "github.com/goccy/go-json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "dappco.re/go/core/forge/types" +) + +func TestRepoService_ListActivityFeeds_Good(t *testing.T) { + date := time.Date(2026, time.April, 2, 15, 4, 5, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/activities/feeds" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("date"); got != "2026-04-02" { + t.Errorf("wrong date: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Activity{{ + ID: 7, + OpType: "create_repo", + Content: "created repository", + }}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + activities, err := f.Repos.ListActivityFeeds(context.Background(), "core", "go-forge", ActivityFeedListOptions{Date: &date}) + if err != nil { + t.Fatal(err) + } + if len(activities) != 1 || activities[0].ID != 7 || activities[0].OpType != "create_repo" { + t.Fatalf("got %#v", activities) + } +} + +func TestRepoService_GetByID_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repositories/42" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Repository{ + ID: 42, + Name: "go-forge", + FullName: "core/go-forge", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.GetByID(context.Background(), 42) + if err != nil { + t.Fatal(err) + } + if repo.ID != 42 || repo.Name != "go-forge" || repo.FullName != "core/go-forge" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_GetRunnerRegistrationToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/runners/registration-token" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("token", "runner-token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + token, err := f.Repos.GetRunnerRegistrationToken(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if token != "runner-token" { + t.Fatalf("got token=%q, want %q", token, "runner-token") + } +} + +func TestRepoService_Migrate_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/migrate" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var opts types.MigrateRepoOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.CloneAddr != "https://example.com/source.git" || opts.RepoName != "go-forge" || opts.RepoOwner != "core" { + t.Fatalf("got %#v", opts) + } + json.NewEncoder(w).Encode(types.Repository{ + ID: 99, + Name: opts.RepoName, + FullName: opts.RepoOwner + "/" + opts.RepoName, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.Migrate(context.Background(), &types.MigrateRepoOptions{ + CloneAddr: "https://example.com/source.git", + RepoName: "go-forge", + RepoOwner: "core", + }) + if err != nil { + t.Fatal(err) + } + if repo.ID != 99 || repo.Name != "go-forge" || repo.FullName != "core/go-forge" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_ListTopics_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/topics" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.TopicName{TopicNames: []string{"go", "forge"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + topics, err := f.Repos.ListTopics(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(topics, []string{"go", "forge"}) { + t.Fatalf("got %#v", topics) + } +} + +func TestRepoService_IterTopics_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/topics" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.TopicName{TopicNames: []string{"go", "forge"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for topic, err := range f.Repos.IterTopics(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, topic) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if !reflect.DeepEqual(got, []string{"go", "forge"}) { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_SearchTopics_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/topics/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "go" { + t.Errorf("wrong query: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.TopicResponse{ + {Name: "go", RepoCount: 10}, + {Name: "forge", RepoCount: 4}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + topics, err := f.Repos.SearchTopics(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(topics) != 2 || topics[0].Name != "go" || topics[1].RepoCount != 4 { + t.Fatalf("got %#v", topics) + } +} + +func TestRepoService_IterSearchTopics_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/topics/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "go" { + t.Errorf("wrong query: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.TopicResponse{{Name: "go", RepoCount: 10}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.TopicResponse + for topic, err := range f.Repos.IterSearchTopics(context.Background(), "go") { + if err != nil { + t.Fatal(err) + } + got = append(got, topic) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 1 || got[0].Name != "go" || got[0].RepoCount != 10 { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_SearchRepositoriesPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "go" { + t.Errorf("wrong query: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "2" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(types.SearchResults{ + Data: []*types.Repository{ + {Name: "go-forge"}, + {Name: "go-core"}, + }, + OK: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Repos.SearchRepositoriesPage(context.Background(), "go", ListOptions{Page: 1, Limit: 2}) + if err != nil { + t.Fatal(err) + } + if page.Page != 1 || page.TotalCount != 3 || !page.HasMore { + t.Fatalf("got %#v", page) + } + if !reflect.DeepEqual(page.Items, []types.Repository{{Name: "go-forge"}, {Name: "go-core"}}) { + t.Fatalf("got %#v", page.Items) + } +} + +func TestRepoService_SearchRepositories_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "go" { + t.Errorf("wrong query: %s", got) + } + switch r.URL.Query().Get("page") { + case "1": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(types.SearchResults{ + Data: []*types.Repository{ + {Name: "go-forge"}, + {Name: "go-core"}, + }, + OK: true, + }) + case "2": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(types.SearchResults{ + Data: []*types.Repository{{Name: "go-utils"}}, + OK: true, + }) + default: + t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Repos.SearchRepositories(context.Background(), "go") + if err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } + if !reflect.DeepEqual(repos, []types.Repository{{Name: "go-forge"}, {Name: "go-core"}, {Name: "go-utils"}}) { + t.Fatalf("got %#v", repos) + } +} + +func TestRepoService_IterSearchRepositories_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode(types.SearchResults{ + Data: []*types.Repository{{Name: "go-forge"}}, + OK: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.Repository + for repo, err := range f.Repos.IterSearchRepositories(context.Background(), "go") { + if err != nil { + t.Fatal(err) + } + got = append(got, repo) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if !reflect.DeepEqual(got, []types.Repository{{Name: "go-forge"}}) { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_UpdateTopics_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/topics" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.RepoTopicOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !reflect.DeepEqual(body.Topics, []string{"go", "forge"}) { + t.Fatalf("got %#v", body.Topics) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.UpdateTopics(context.Background(), "core", "go-forge", []string{"go", "forge"}); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_AddTopic_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.EscapedPath() != "/api/v1/repos/core/go-forge/topics/release%20candidate" { + t.Errorf("wrong path: %s", r.URL.EscapedPath()) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.AddTopic(context.Background(), "core", "go-forge", "release candidate"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteTopic_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.EscapedPath() != "/api/v1/repos/core/go-forge/topics/release%20candidate" { + t.Errorf("wrong path: %s", r.URL.EscapedPath()) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteTopic(context.Background(), "core", "go-forge", "release candidate"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_GetTag_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tags/v1.0.0" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Tag{ + Name: "v1.0.0", + Message: "Release 1.0.0", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tag, err := f.Repos.GetTag(context.Background(), "core", "go-forge", "v1.0.0") + if err != nil { + t.Fatal(err) + } + if tag.Name != "v1.0.0" || tag.Message != "Release 1.0.0" { + t.Fatalf("got %#v", tag) + } +} + +func TestRepoService_DeleteTag_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tags/v1.0.0" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteTag(context.Background(), "core", "go-forge", "v1.0.0"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_GetLanguages_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/languages" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]int64{ + "go": 1200, + "shell": 300, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + languages, err := f.Repos.GetLanguages(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(languages, map[string]int64{"go": 1200, "shell": 300}) { + t.Fatalf("got %#v", languages) + } +} + +func TestRepoService_GetRawFileOrLFS_Good(t *testing.T) { + want := []byte("lfs-pointer-or-content") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/media/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Errorf("wrong ref: %s", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(want) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + got, err := f.Repos.GetRawFileOrLFS(context.Background(), "core", "go-forge", "README.md", "main") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRepoService_GetEditorConfig_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/editorconfig/README.md" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Errorf("wrong ref: %s", got) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.GetEditorConfig(context.Background(), "core", "go-forge", "README.md", "main"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_GetEditorConfig_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.GetEditorConfig(context.Background(), "core", "go-forge", "README.md", "main"); !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestRepoService_ApplyDiffPatch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/diffpatch" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.UpdateFileOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.SHA != "abc123" || body.Message != "apply patch" || body.ContentBase64 != "ZGlmZiBjb250ZW50" { + t.Fatalf("got %#v", body) + } + json.NewEncoder(w).Encode(types.FileResponse{ + Commit: &types.FileCommitResponse{SHA: "commit-1"}, + Content: &types.ContentsResponse{ + Path: "README.md", + SHA: "file-1", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Repos.ApplyDiffPatch(context.Background(), "core", "go-forge", &types.UpdateFileOptions{ + SHA: "abc123", + Message: "apply patch", + ContentBase64: "ZGlmZiBjb250ZW50", + }) + if err != nil { + t.Fatal(err) + } + if resp.Commit == nil || resp.Commit.SHA != "commit-1" { + t.Fatalf("got %#v", resp) + } + if resp.Content == nil || resp.Content.Path != "README.md" || resp.Content.SHA != "file-1" { + t.Fatalf("got %#v", resp) + } +} + +func TestRepoService_ListForks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/forks" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Repository{ + {ID: 11, Name: "go-forge-fork", FullName: "alice/go-forge-fork"}, + {ID: 12, Name: "go-forge-fork-2", FullName: "bob/go-forge-fork-2"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + forks, err := f.Repos.ListForks(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(forks) != 2 || forks[0].FullName != "alice/go-forge-fork" || forks[1].FullName != "bob/go-forge-fork-2" { + t.Fatalf("got %#v", forks) + } +} + +func TestRepoService_ListTagProtections_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tag_protections" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.TagProtection{ + {ID: 1, NamePattern: "v*"}, + {ID: 2, NamePattern: "release-*"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tagProtections, err := f.Repos.ListTagProtections(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(tagProtections) != 2 || tagProtections[0].ID != 1 || tagProtections[1].NamePattern != "release-*" { + t.Fatalf("got %#v", tagProtections) + } +} + +func TestRepoService_GetTagProtection_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tag_protections/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.TagProtection{ + ID: 7, + NamePattern: "v*", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tagProtection, err := f.Repos.GetTagProtection(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if tagProtection.ID != 7 || tagProtection.NamePattern != "v*" { + t.Fatalf("got %#v", tagProtection) + } +} + +func TestRepoService_CreateTagProtection_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tag_protections" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.CreateTagProtectionOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.NamePattern != "v*" || !reflect.DeepEqual(body.WhitelistTeams, []string{"release-team"}) || !reflect.DeepEqual(body.WhitelistUsernames, []string{"alice"}) { + t.Fatalf("got %#v", body) + } + json.NewEncoder(w).Encode(types.TagProtection{ + ID: 9, + NamePattern: body.NamePattern, + WhitelistTeams: body.WhitelistTeams, + WhitelistUsernames: body.WhitelistUsernames, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tagProtection, err := f.Repos.CreateTagProtection(context.Background(), "core", "go-forge", &types.CreateTagProtectionOption{ + NamePattern: "v*", + WhitelistTeams: []string{"release-team"}, + WhitelistUsernames: []string{"alice"}, + }) + if err != nil { + t.Fatal(err) + } + if tagProtection.ID != 9 || tagProtection.NamePattern != "v*" { + t.Fatalf("got %#v", tagProtection) + } +} + +func TestRepoService_EditTagProtection_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tag_protections/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.EditTagProtectionOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.NamePattern != "release-*" || !reflect.DeepEqual(body.WhitelistTeams, []string{"release-team"}) { + t.Fatalf("got %#v", body) + } + json.NewEncoder(w).Encode(types.TagProtection{ + ID: 7, + NamePattern: body.NamePattern, + WhitelistTeams: body.WhitelistTeams, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tagProtection, err := f.Repos.EditTagProtection(context.Background(), "core", "go-forge", 7, &types.EditTagProtectionOption{ + NamePattern: "release-*", + WhitelistTeams: []string{"release-team"}, + }) + if err != nil { + t.Fatal(err) + } + if tagProtection.ID != 7 || tagProtection.NamePattern != "release-*" { + t.Fatalf("got %#v", tagProtection) + } +} + +func TestRepoService_DeleteTagProtection_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tag_protections/7" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteTagProtection(context.Background(), "core", "go-forge", 7); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_ListKeysWithFilters_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("key_id"); got != "7" { + t.Errorf("got key_id=%q, want %q", got, "7") + } + if got := r.URL.Query().Get("fingerprint"); got != "aa:bb:cc" { + t.Errorf("got fingerprint=%q, want %q", got, "aa:bb:cc") + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("got limit=%q, want %q", got, "50") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.DeployKey{{ID: 7, Title: "deploy"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Repos.ListKeys(context.Background(), "core", "go-forge", RepoKeyListOptions{ + KeyID: 7, + Fingerprint: "aa:bb:cc", + }) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 { + t.Fatalf("got %d keys, want 1", len(keys)) + } + if keys[0].ID != 7 || keys[0].Title != "deploy" { + t.Fatalf("got %#v", keys[0]) + } +} + +func TestRepoService_GetKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/keys/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.DeployKey{ID: 7, Title: "deploy"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Repos.GetKey(context.Background(), "core", "go-forge", 7) + if err != nil { + t.Fatal(err) + } + if key.ID != 7 || key.Title != "deploy" { + t.Fatalf("got %#v", key) + } +} + +func TestRepoService_CreateKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateKeyOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Title != "deploy" || opts.Key != "ssh-ed25519 AAAA..." || !opts.ReadOnly { + t.Fatalf("got %#v", opts) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.DeployKey{ID: 9, Title: opts.Title, Key: opts.Key, ReadOnly: opts.ReadOnly}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Repos.CreateKey(context.Background(), "core", "go-forge", &types.CreateKeyOption{ + Title: "deploy", + Key: "ssh-ed25519 AAAA...", + ReadOnly: true, + }) + if err != nil { + t.Fatal(err) + } + if key.ID != 9 || key.Title != "deploy" || !key.ReadOnly { + t.Fatalf("got %#v", key) + } +} + +func TestRepoService_DeleteKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/keys/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteKey(context.Background(), "core", "go-forge", 7); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteTag_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/tags/missing" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Repos.DeleteTag(context.Background(), "core", "go-forge", "missing") + if err == nil { + t.Fatal("expected error") + } + if !IsNotFound(err) { + t.Fatalf("got %v, want not found", err) + } +} + +func TestRepoService_ListIssueTemplates_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issue_templates" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.IssueTemplate{ + { + Name: "bug report", + Title: "Bug report", + Content: "Describe the problem", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + templates, err := f.Repos.ListIssueTemplates(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(templates) != 1 { + t.Fatalf("got %d templates, want 1", len(templates)) + } + if templates[0].Name != "bug report" || templates[0].Title != "Bug report" { + t.Fatalf("got %#v", templates[0]) + } +} + +func TestRepoService_IterIssueTemplates_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issue_templates" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.IssueTemplate{ + { + Name: "bug report", + Title: "Bug report", + Content: "Describe the problem", + }, + { + Name: "feature request", + Title: "Feature request", + Content: "Describe the idea", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for template, err := range f.Repos.IterIssueTemplates(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, template.Name) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if len(got) != 2 || got[0] != "bug report" || got[1] != "feature request" { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_GetIssueConfig_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issue_config" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.IssueConfig{ + BlankIssuesEnabled: true, + ContactLinks: []*types.IssueConfigContactLink{{ + Name: "Security", + URL: "https://example.com/security", + About: "Report a vulnerability", + }}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + cfg, err := f.Repos.GetIssueConfig(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !cfg.BlankIssuesEnabled { + t.Fatalf("expected blank issues to be enabled, got %#v", cfg) + } + if len(cfg.ContactLinks) != 1 || cfg.ContactLinks[0].Name != "Security" { + t.Fatalf("got %#v", cfg.ContactLinks) + } +} + +func TestRepoService_ValidateIssueConfig_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issue_config/validate" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.IssueConfigValidation{ + Valid: false, + Message: "invalid contact link URL", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Repos.ValidateIssueConfig(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if result.Valid || result.Message != "invalid contact link URL" { + t.Fatalf("got %#v", result) + } +} + +func TestRepoService_GetNewPinAllowed_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/new_pin_allowed" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.NewIssuePinsAllowed{ + Issues: true, + PullRequests: false, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Repos.GetNewPinAllowed(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !result.Issues || result.PullRequests { + t.Fatalf("got %#v", result) + } +} + +func TestRepoService_UpdateAvatar_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/avatar" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.UpdateRepoAvatarOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Image != "iVBORw0KGgoAAAANSUhEUg==" { + t.Fatalf("got image=%q", body.Image) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.UpdateAvatar(context.Background(), "core", "go-forge", &types.UpdateRepoAvatarOption{ + Image: "iVBORw0KGgoAAAANSUhEUg==", + }); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteAvatar_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/avatar" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteAvatar(context.Background(), "core", "go-forge"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_ListPushMirrors_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/push_mirrors" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PushMirror{ + {RemoteName: "mirror-a"}, + {RemoteName: "mirror-b"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + mirrors, err := f.Repos.ListPushMirrors(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(mirrors) != 2 || mirrors[0].RemoteName != "mirror-a" || mirrors[1].RemoteName != "mirror-b" { + t.Fatalf("got %#v", mirrors) + } +} + +func TestRepoService_GetPushMirror_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/push_mirrors/mirror-a" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.PushMirror{ + RemoteName: "mirror-a", + RemoteAddress: "ssh://git@example.com/core/go-forge.git", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + mirror, err := f.Repos.GetPushMirror(context.Background(), "core", "go-forge", "mirror-a") + if err != nil { + t.Fatal(err) + } + if mirror.RemoteName != "mirror-a" { + t.Fatalf("got remote_name=%q", mirror.RemoteName) + } +} + +func TestRepoService_CreatePushMirror_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/push_mirrors" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.CreatePushMirrorOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.RemoteAddress != "ssh://git@example.com/core/go-forge.git" || !body.SyncOnCommit { + t.Fatalf("got %#v", body) + } + json.NewEncoder(w).Encode(types.PushMirror{ + RemoteName: "mirror-a", + RemoteAddress: body.RemoteAddress, + SyncOnCommit: body.SyncOnCommit, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + mirror, err := f.Repos.CreatePushMirror(context.Background(), "core", "go-forge", &types.CreatePushMirrorOption{ + RemoteAddress: "ssh://git@example.com/core/go-forge.git", + RemoteUsername: "git", + RemotePassword: "secret", + Interval: "1h", + SyncOnCommit: true, + UseSSH: true, + }) + if err != nil { + t.Fatal(err) + } + if mirror.RemoteName != "mirror-a" || !mirror.SyncOnCommit { + t.Fatalf("got %#v", mirror) + } +} + +func TestRepoService_DeletePushMirror_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/push_mirrors/mirror-a" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeletePushMirror(context.Background(), "core", "go-forge", "mirror-a"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_SyncPushMirrors_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/push_mirrors-sync" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.SyncPushMirrors(context.Background(), "core", "go-forge"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_GetSubscription_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/subscription" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.WatchInfo{ + Subscribed: true, + Ignored: false, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Repos.GetSubscription(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !result.Subscribed || result.Ignored { + t.Fatalf("got %#v", result) + } +} + +func TestRepoService_ListPinnedPullRequests_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/pinned" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PullRequest{ + {ID: 7, Title: "pin me"}, + {ID: 8, Title: "keep me"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pulls, err := f.Repos.ListPinnedPullRequests(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if got, want := len(pulls), 2; got != want { + t.Fatalf("got %d pull requests, want %d", got, want) + } + if pulls[0].Title != "pin me" { + t.Fatalf("got first title %q", pulls[0].Title) + } +} + +func TestRepoService_IterPinnedPullRequests_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls/pinned" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + switch requests { + case 1: + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 7, Title: "pin me"}}) + case 2: + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 8, Title: "keep me"}}) + default: + t.Fatalf("unexpected request %d", requests) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for pr, err := range f.Repos.IterPinnedPullRequests(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, pr.Title) + } + if len(got) != 2 || got[0] != "pin me" || got[1] != "keep me" { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_ListStargazers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/stargazers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.User{{UserName: "alice"}, {UserName: "bob"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Repos.ListStargazers(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(users) != 2 || users[0].UserName != "alice" || users[1].UserName != "bob" { + t.Fatalf("got %#v", users) + } +} + +func TestRepoService_ListSubscribers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/subscribers" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.User{{UserName: "charlie"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Repos.ListSubscribers(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(users) != 1 || users[0].UserName != "charlie" { + t.Fatalf("got %#v", users) + } +} + +func TestRepoService_Compare_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/compare/main...feature" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Compare{ + TotalCommits: 2, + Commits: []*types.Commit{ + {SHA: "abc123"}, + {SHA: "def456"}, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + got, err := f.Repos.Compare(context.Background(), "core", "go-forge", "main...feature") + if err != nil { + t.Fatal(err) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if got.TotalCommits != 2 || len(got.Commits) != 2 || got.Commits[0].SHA != "abc123" || got.Commits[1].SHA != "def456" { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_GetSigningKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/signing-key.gpg" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\n...")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Repos.GetSigningKey(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + want := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n..." + if key != want { + t.Fatalf("got %q, want %q", key, want) + } +} + +func TestRepoService_ListFlags_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/flags" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]string{"alpha", "beta"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + flags, err := f.Repos.ListFlags(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(flags, []string{"alpha", "beta"}) { + t.Fatalf("got %#v", flags) + } +} + +func TestRepoService_IterFlags_Good(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/flags" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("got limit=%q, want %q", got, "50") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]string{"alpha", "beta"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for flag, err := range f.Repos.IterFlags(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, flag) + } + if requests != 1 { + t.Fatalf("expected 1 request, got %d", requests) + } + if !reflect.DeepEqual(got, []string{"alpha", "beta"}) { + t.Fatalf("got %#v", got) + } +} + +func TestRepoService_ReplaceFlags_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/flags" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.ReplaceFlagsOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !reflect.DeepEqual(body.Flags, []string{"alpha", "beta"}) { + t.Fatalf("got %#v", body.Flags) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.ReplaceFlags(context.Background(), "core", "go-forge", &types.ReplaceFlagsOption{Flags: []string{"alpha", "beta"}}); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteFlags_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/flags" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteFlags(context.Background(), "core", "go-forge"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_ListAssignees_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/assignees" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.User{{UserName: "alice"}, {UserName: "bob"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Repos.ListAssignees(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(users) != 2 || users[0].UserName != "alice" || users[1].UserName != "bob" { + t.Fatalf("got %#v", users) + } +} + +func TestRepoService_IterAssignees_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/assignees" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.User{{UserName: "alice"}, {UserName: "bob"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var names []string + for user, err := range f.Repos.IterAssignees(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + names = append(names, user.UserName) + } + if len(names) != 2 || names[0] != "alice" || names[1] != "bob" { + t.Fatalf("got %#v", names) + } +} + +func TestRepoService_ListCollaborators_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/collaborators" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.User{{UserName: "alice"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Repos.ListCollaborators(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(users) != 1 || users[0].UserName != "alice" { + t.Fatalf("got %#v", users) + } +} + +func TestRepoService_AddCollaborator_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/collaborators/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.AddCollaboratorOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Permission != "write" { + t.Fatalf("got permission=%q, want %q", body.Permission, "write") + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.AddCollaborator(context.Background(), "core", "go-forge", "alice", &types.AddCollaboratorOption{Permission: "write"}); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteCollaborator_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/collaborators/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteCollaborator(context.Background(), "core", "go-forge", "alice"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_CheckCollaborator_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/collaborators/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + ok, err := f.Repos.CheckCollaborator(context.Background(), "core", "go-forge", "alice") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected collaborator check to return true") + } +} + +func TestRepoService_GetCollaboratorPermission_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/collaborators/alice/permission" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.RepoCollaboratorPermission{ + Permission: "write", + RoleName: "collaborator", + User: &types.User{UserName: "alice"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + perm, err := f.Repos.GetCollaboratorPermission(context.Background(), "core", "go-forge", "alice") + if err != nil { + t.Fatal(err) + } + if perm.Permission != "write" || perm.User == nil || perm.User.UserName != "alice" { + t.Fatalf("got %#v", perm) + } + + perm, err = f.Repos.GetRepoPermissions(context.Background(), "core", "go-forge", "alice") + if err != nil { + t.Fatal(err) + } + if perm.Permission != "write" || perm.User == nil || perm.User.UserName != "alice" { + t.Fatalf("got %#v", perm) + } +} + +func TestRepoService_ListRepoTeams_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/teams" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.Team{{ID: 7, Name: "platform"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + teams, err := f.Repos.ListRepoTeams(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(teams) != 1 || teams[0].ID != 7 || teams[0].Name != "platform" { + t.Fatalf("got %#v", teams) + } +} + +func TestRepoService_GetRepoTeam_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/teams/platform" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.Team{ID: 7, Name: "platform"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + team, err := f.Repos.GetRepoTeam(context.Background(), "core", "go-forge", "platform") + if err != nil { + t.Fatal(err) + } + if team.ID != 7 || team.Name != "platform" { + t.Fatalf("got %#v", team) + } +} + +func TestRepoService_AddRepoTeam_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/teams/platform" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.AddRepoTeam(context.Background(), "core", "go-forge", "platform"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_DeleteRepoTeam_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/teams/platform" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.DeleteRepoTeam(context.Background(), "core", "go-forge", "platform"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_Watch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/subscription" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(types.WatchInfo{ + Subscribed: true, + Ignored: false, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Repos.Watch(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !result.Subscribed || result.Ignored { + t.Fatalf("got %#v", result) + } +} + +func TestRepoService_Unwatch_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/subscription" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Repos.Unwatch(context.Background(), "core", "go-forge"); err != nil { + t.Fatal(err) + } +} + +func TestRepoService_ForkWithOptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/forks" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var opts types.CreateForkOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.Name != "go-forge-fork" || opts.Organization != "core-team" { + t.Fatalf("got %#v", opts) + } + json.NewEncoder(w).Encode(types.Repository{ + Name: opts.Name, + FullName: opts.Organization + "/" + opts.Name, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.ForkWithOptions(context.Background(), "core", "go-forge", &types.CreateForkOption{ + Name: "go-forge-fork", + Organization: "core-team", + }) + if err != nil { + t.Fatal(err) + } + if repo.Name != "go-forge-fork" || repo.FullName != "core-team/go-forge-fork" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_Transfer_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/transfer" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body types.TransferRepoOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.NewOwner != "core-team" || !reflect.DeepEqual(body.TeamIDs, []int64{7, 9}) { + t.Fatalf("got %#v", body) + } + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", FullName: "core-team/go-forge"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.Transfer(context.Background(), "core", "go-forge", &types.TransferRepoOption{ + NewOwner: "core-team", + TeamIDs: []int64{7, 9}, + }) + if err != nil { + t.Fatal(err) + } + if repo.Name != "go-forge" || repo.FullName != "core-team/go-forge" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_AcceptTransfer_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/transfer/accept" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(body) != 0 { + t.Fatalf("expected empty body, got %q", body) + } + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", FullName: "core/go-forge"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.AcceptTransfer(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if repo.Name != "go-forge" || repo.FullName != "core/go-forge" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_RejectTransfer_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/transfer/reject" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(body) != 0 { + t.Fatalf("expected empty body, got %q", body) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Repository{Name: "go-forge", FullName: "core/go-forge"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.RejectTransfer(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if repo.Name != "go-forge" || repo.FullName != "core/go-forge" { + t.Fatalf("got %#v", repo) + } +} + +func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { + owner := "acme org" + repo := "my/repo" + org := "team alpha" + + t.Run("ListOrgRepos", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/api/v1/orgs/team%20alpha/repos" { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), "/api/v1/orgs/team%20alpha/repos") + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode([]types.Repository{}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Repos.ListOrgRepos(context.Background(), org) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("CreateOrgRepo", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/api/v1/orgs/team%20alpha/repos" + if r.URL.EscapedPath() != want { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), want) + http.NotFound(w, r) + return + } + var opts types.CreateRepoOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.Name != "go-forge" || !opts.Private { + t.Fatalf("got %#v", opts) + } + json.NewEncoder(w).Encode(types.Repository{Name: opts.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Repos.CreateOrgRepo(context.Background(), org, &types.CreateRepoOption{ + Name: "go-forge", + Private: true, + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("CreateOrgRepoDeprecated", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/api/v1/org/team%20alpha/repos" + if r.URL.EscapedPath() != want { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), want) + http.NotFound(w, r) + return + } + var opts types.CreateRepoOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.Name != "go-forge" || !opts.Private { + t.Fatalf("got %#v", opts) + } + json.NewEncoder(w).Encode(types.Repository{Name: opts.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Repos.CreateOrgRepoDeprecated(context.Background(), org, &types.CreateRepoOption{ + Name: "go-forge", + Private: true, + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("CreateCurrentUserRepo", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.EscapedPath() != "/api/v1/user/repos" { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), "/api/v1/user/repos") + http.NotFound(w, r) + return + } + var opts types.CreateRepoOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.Name != "go-forge" || !opts.Private { + t.Fatalf("got %#v", opts) + } + json.NewEncoder(w).Encode(types.Repository{Name: opts.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Repos.CreateCurrentUserRepo(context.Background(), &types.CreateRepoOption{ + Name: "go-forge", + Private: true, + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("Fork", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/api/v1/repos/acme%20org/my%2Frepo/forks" + if r.URL.EscapedPath() != want { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), want) + http.NotFound(w, r) + return + } + var opts types.CreateForkOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatalf("decode body: %v", err) + } + if opts.Organization != "" { + t.Fatalf("got organisation %q, want empty", opts.Organization) + } + json.NewEncoder(w).Encode(types.Repository{Name: repo}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if _, err := f.Repos.Fork(context.Background(), owner, repo, ""); err != nil { + t.Fatal(err) + } + }) + + t.Run("Generate", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/api/v1/repos/acme%20org/template%2Frepo/generate" + if r.URL.EscapedPath() != want { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), want) + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + var body types.GenerateRepoOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Owner != "acme org" || body.Name != "generated repo" || !body.Private || !body.Topics { + t.Fatalf("got %#v", body) + } + json.NewEncoder(w).Encode(types.Repository{Name: body.Name, FullName: "acme org/" + body.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repo, err := f.Repos.Generate(context.Background(), owner, "template/repo", &types.GenerateRepoOption{ + Owner: "acme org", + Name: "generated repo", + Private: true, + Topics: true, + }) + if err != nil { + t.Fatal(err) + } + if repo.Name != "generated repo" || repo.FullName != "acme org/generated repo" { + t.Fatalf("got %#v", repo) + } + }) +} diff --git a/resource.go b/resource.go index 04f073c..712a610 100644 --- a/resource.go +++ b/resource.go @@ -3,37 +3,104 @@ package forge import ( "context" "iter" + "strconv" + + core "dappco.re/go/core" ) // Resource provides generic CRUD operations for a Forgejo API resource. // T is the resource type, C is the create options type, U is the update options type. +// +// Usage: +// +// r := forge.NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption](client, "/api/v1/repos/{owner}/{repo}/issues/{index}") +// _ = r type Resource[T any, C any, U any] struct { - client *Client - path string + client *Client + path string // item path: /api/v1/repos/{owner}/{repo}/issues/{index} + collection string // collection path: /api/v1/repos/{owner}/{repo}/issues +} + +// String returns a safe summary of the resource configuration. +// +// Usage: +// +// s := res.String() +func (r *Resource[T, C, U]) String() string { + if r == nil { + return "forge.Resource{}" + } + return core.Concat( + "forge.Resource{path=", + strconv.Quote(r.path), + ", collection=", + strconv.Quote(r.collection), + "}", + ) } +// GoString returns a safe Go-syntax summary of the resource configuration. +// +// Usage: +// +// s := fmt.Sprintf("%#v", res) +func (r *Resource[T, C, U]) GoString() string { return r.String() } + // NewResource creates a new Resource for the given path pattern. -// The path may contain {placeholders} that are resolved via Params. +// The path should be the item path (e.g., /repos/{owner}/{repo}/issues/{index}). +// The collection path is derived by stripping the last /{placeholder} segment. +// +// Usage: +// +// r := forge.NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption](client, "/api/v1/repos/{owner}/{repo}/issues/{index}") +// _ = r func NewResource[T any, C any, U any](c *Client, path string) *Resource[T, C, U] { - return &Resource[T, C, U]{client: c, path: path} + collection := path + // Strip last segment if it's a pure placeholder like /{index} + // Don't strip if mixed like /repos or /{org}/repos + if i := lastIndexByte(path, '/'); i >= 0 { + lastSeg := path[i+1:] + if core.HasPrefix(lastSeg, "{") && core.HasSuffix(lastSeg, "}") { + collection = path[:i] + } + } + return &Resource[T, C, U]{client: c, path: path, collection: collection} } // List returns a single page of resources. +// +// Usage: +// +// page, err := res.List(ctx, forge.Params{"owner": "core"}, forge.DefaultList) func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[T], error) { - return ListPage[T](ctx, r.client, ResolvePath(r.path, params), nil, opts) + return ListPage[T](ctx, r.client, ResolvePath(r.collection, params), nil, opts) } // ListAll returns all resources across all pages. +// +// Usage: +// +// items, err := res.ListAll(ctx, forge.Params{"owner": "core"}) func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error) { - return ListAll[T](ctx, r.client, ResolvePath(r.path, params), nil) + return ListAll[T](ctx, r.client, ResolvePath(r.collection, params), nil) } // Iter returns an iterator over all resources across all pages. +// +// Usage: +// +// for item, err := range res.Iter(ctx, forge.Params{"owner": "core"}) { +// _, _ = item, err +// } func (r *Resource[T, C, U]) Iter(ctx context.Context, params Params) iter.Seq2[T, error] { - return ListIter[T](ctx, r.client, ResolvePath(r.path, params), nil) + return ListIter[T](ctx, r.client, ResolvePath(r.collection, params), nil) } // Get returns a single resource by appending id to the path. +// +// Usage: +// +// item, err := res.Get(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error) { var out T if err := r.client.Get(ctx, ResolvePath(r.path, params), &out); err != nil { @@ -43,15 +110,23 @@ func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error) } // Create creates a new resource. +// +// Usage: +// +// item, err := res.Create(ctx, forge.Params{"owner": "core"}, body) func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error) { var out T - if err := r.client.Post(ctx, ResolvePath(r.path, params), body, &out); err != nil { + if err := r.client.Post(ctx, ResolvePath(r.collection, params), body, &out); err != nil { return nil, err } return &out, nil } // Update modifies an existing resource. +// +// Usage: +// +// item, err := res.Update(ctx, forge.Params{"owner": "core", "repo": "go-forge"}, body) func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, body *U) (*T, error) { var out T if err := r.client.Patch(ctx, ResolvePath(r.path, params), body, &out); err != nil { @@ -61,6 +136,10 @@ func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, body *U) } // Delete removes a resource. +// +// Usage: +// +// err := res.Delete(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params) error { return r.client.Delete(ctx, ResolvePath(r.path, params)) } diff --git a/resource_string_test.go b/resource_string_test.go new file mode 100644 index 0000000..15e1da2 --- /dev/null +++ b/resource_string_test.go @@ -0,0 +1,21 @@ +package forge + +import ( + "fmt" + "testing" +) + +func TestResource_String_Good(t *testing.T) { + res := NewResource[int, struct{}, struct{}](NewClient("https://forge.lthn.ai", "tok"), "/api/v1/repos/{owner}/{repo}") + got := fmt.Sprint(res) + want := `forge.Resource{path="/api/v1/repos/{owner}/{repo}", collection="/api/v1/repos/{owner}"}` + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := res.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", res); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} diff --git a/resource_test.go b/resource_test.go index 0b00b81..8f81d25 100644 --- a/resource_test.go +++ b/resource_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -22,7 +22,7 @@ type testUpdate struct { Name *string `json:"name,omitempty"` } -func TestResource_Good_List(t *testing.T) { +func TestResource_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/orgs/core/repos" { t.Errorf("wrong path: %s", r.URL.Path) @@ -44,7 +44,7 @@ func TestResource_Good_List(t *testing.T) { } } -func TestResource_Good_Get(t *testing.T) { +func TestResource_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/core/go-forge" { t.Errorf("wrong path: %s", r.URL.Path) @@ -65,11 +65,16 @@ func TestResource_Good_Get(t *testing.T) { } } -func TestResource_Good_Create(t *testing.T) { +func TestResource_Create_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } + if r.URL.Path != "/api/v1/orgs/core/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } var body testCreate json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(http.StatusCreated) @@ -89,7 +94,7 @@ func TestResource_Good_Create(t *testing.T) { } } -func TestResource_Good_Update(t *testing.T) { +func TestResource_Update_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) @@ -111,7 +116,7 @@ func TestResource_Good_Update(t *testing.T) { } } -func TestResource_Good_Delete(t *testing.T) { +func TestResource_Delete_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -129,7 +134,7 @@ func TestResource_Good_Delete(t *testing.T) { } } -func TestResource_Good_ListAll(t *testing.T) { +func TestResource_ListAll_Good(t *testing.T) { page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page++ @@ -154,7 +159,7 @@ func TestResource_Good_ListAll(t *testing.T) { } } -func TestResource_Good_Iter(t *testing.T) { +func TestResource_Iter_Good(t *testing.T) { page := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { page++ @@ -185,7 +190,7 @@ func TestResource_Good_Iter(t *testing.T) { } } -func TestResource_Bad_IterError(t *testing.T) { +func TestResource_IterError_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "server error"}) @@ -207,7 +212,7 @@ func TestResource_Bad_IterError(t *testing.T) { } } -func TestResource_Good_IterBreakEarly(t *testing.T) { +func TestResource_IterBreakEarly_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Total-Count", "100") json.NewEncoder(w).Encode([]testItem{{1, "a"}, {2, "b"}, {3, "c"}}) diff --git a/service_string.go b/service_string.go new file mode 100644 index 0000000..c29476a --- /dev/null +++ b/service_string.go @@ -0,0 +1,421 @@ +package forge + +// String returns a safe summary of the actions service. +// +// Usage: +// +// s := &forge.ActionsService{} +// _ = s.String() +func (s *ActionsService) String() string { + if s == nil { + return "forge.ActionsService{}" + } + return serviceString("forge.ActionsService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the actions service. +// +// Usage: +// +// s := &forge.ActionsService{} +// _ = fmt.Sprintf("%#v", s) +func (s *ActionsService) GoString() string { return s.String() } + +// String returns a safe summary of the ActivityPub service. +// +// Usage: +// +// s := &forge.ActivityPubService{} +// _ = s.String() +func (s *ActivityPubService) String() string { + if s == nil { + return "forge.ActivityPubService{}" + } + return serviceString("forge.ActivityPubService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the ActivityPub service. +// +// Usage: +// +// s := &forge.ActivityPubService{} +// _ = fmt.Sprintf("%#v", s) +func (s *ActivityPubService) GoString() string { return s.String() } + +// String returns a safe summary of the admin service. +// +// Usage: +// +// s := &forge.AdminService{} +// _ = s.String() +func (s *AdminService) String() string { + if s == nil { + return "forge.AdminService{}" + } + return serviceString("forge.AdminService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the admin service. +// +// Usage: +// +// s := &forge.AdminService{} +// _ = fmt.Sprintf("%#v", s) +func (s *AdminService) GoString() string { return s.String() } + +// String returns a safe summary of the branch service. +// +// Usage: +// +// s := &forge.BranchService{} +// _ = s.String() +func (s *BranchService) String() string { + if s == nil { + return "forge.BranchService{}" + } + return serviceString("forge.BranchService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the branch service. +// +// Usage: +// +// s := &forge.BranchService{} +// _ = fmt.Sprintf("%#v", s) +func (s *BranchService) GoString() string { return s.String() } + +// String returns a safe summary of the commit service. +// +// Usage: +// +// s := &forge.CommitService{} +// _ = s.String() +func (s *CommitService) String() string { + if s == nil { + return "forge.CommitService{}" + } + return serviceString("forge.CommitService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the commit service. +// +// Usage: +// +// s := &forge.CommitService{} +// _ = fmt.Sprintf("%#v", s) +func (s *CommitService) GoString() string { return s.String() } + +// String returns a safe summary of the content service. +// +// Usage: +// +// s := &forge.ContentService{} +// _ = s.String() +func (s *ContentService) String() string { + if s == nil { + return "forge.ContentService{}" + } + return serviceString("forge.ContentService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the content service. +// +// Usage: +// +// s := &forge.ContentService{} +// _ = fmt.Sprintf("%#v", s) +func (s *ContentService) GoString() string { return s.String() } + +// String returns a safe summary of the issue service. +// +// Usage: +// +// s := &forge.IssueService{} +// _ = s.String() +func (s *IssueService) String() string { + if s == nil { + return "forge.IssueService{}" + } + return serviceString("forge.IssueService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the issue service. +// +// Usage: +// +// s := &forge.IssueService{} +// _ = fmt.Sprintf("%#v", s) +func (s *IssueService) GoString() string { return s.String() } + +// String returns a safe summary of the label service. +// +// Usage: +// +// s := &forge.LabelService{} +// _ = s.String() +func (s *LabelService) String() string { + if s == nil { + return "forge.LabelService{}" + } + return serviceString("forge.LabelService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the label service. +// +// Usage: +// +// s := &forge.LabelService{} +// _ = fmt.Sprintf("%#v", s) +func (s *LabelService) GoString() string { return s.String() } + +// String returns a safe summary of the milestone service. +// +// Usage: +// +// s := &forge.MilestoneService{} +// _ = s.String() +func (s *MilestoneService) String() string { + if s == nil { + return "forge.MilestoneService{}" + } + return serviceString("forge.MilestoneService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the milestone service. +// +// Usage: +// +// s := &forge.MilestoneService{} +// _ = fmt.Sprintf("%#v", s) +func (s *MilestoneService) GoString() string { return s.String() } + +// String returns a safe summary of the misc service. +// +// Usage: +// +// s := &forge.MiscService{} +// _ = s.String() +func (s *MiscService) String() string { + if s == nil { + return "forge.MiscService{}" + } + return serviceString("forge.MiscService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the misc service. +// +// Usage: +// +// s := &forge.MiscService{} +// _ = fmt.Sprintf("%#v", s) +func (s *MiscService) GoString() string { return s.String() } + +// String returns a safe summary of the notification service. +// +// Usage: +// +// s := &forge.NotificationService{} +// _ = s.String() +func (s *NotificationService) String() string { + if s == nil { + return "forge.NotificationService{}" + } + return serviceString("forge.NotificationService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the notification service. +// +// Usage: +// +// s := &forge.NotificationService{} +// _ = fmt.Sprintf("%#v", s) +func (s *NotificationService) GoString() string { return s.String() } + +// String returns a safe summary of the organisation service. +// +// Usage: +// +// s := &forge.OrgService{} +// _ = s.String() +func (s *OrgService) String() string { + if s == nil { + return "forge.OrgService{}" + } + return serviceString("forge.OrgService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the organisation service. +// +// Usage: +// +// s := &forge.OrgService{} +// _ = fmt.Sprintf("%#v", s) +func (s *OrgService) GoString() string { return s.String() } + +// String returns a safe summary of the package service. +// +// Usage: +// +// s := &forge.PackageService{} +// _ = s.String() +func (s *PackageService) String() string { + if s == nil { + return "forge.PackageService{}" + } + return serviceString("forge.PackageService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the package service. +// +// Usage: +// +// s := &forge.PackageService{} +// _ = fmt.Sprintf("%#v", s) +func (s *PackageService) GoString() string { return s.String() } + +// String returns a safe summary of the pull request service. +// +// Usage: +// +// s := &forge.PullService{} +// _ = s.String() +func (s *PullService) String() string { + if s == nil { + return "forge.PullService{}" + } + return serviceString("forge.PullService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the pull request service. +// +// Usage: +// +// s := &forge.PullService{} +// _ = fmt.Sprintf("%#v", s) +func (s *PullService) GoString() string { return s.String() } + +// String returns a safe summary of the release service. +// +// Usage: +// +// s := &forge.ReleaseService{} +// _ = s.String() +func (s *ReleaseService) String() string { + if s == nil { + return "forge.ReleaseService{}" + } + return serviceString("forge.ReleaseService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the release service. +// +// Usage: +// +// s := &forge.ReleaseService{} +// _ = fmt.Sprintf("%#v", s) +func (s *ReleaseService) GoString() string { return s.String() } + +// String returns a safe summary of the repository service. +// +// Usage: +// +// s := &forge.RepoService{} +// _ = s.String() +func (s *RepoService) String() string { + if s == nil { + return "forge.RepoService{}" + } + return serviceString("forge.RepoService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the repository service. +// +// Usage: +// +// s := &forge.RepoService{} +// _ = fmt.Sprintf("%#v", s) +func (s *RepoService) GoString() string { return s.String() } + +// String returns a safe summary of the team service. +// +// Usage: +// +// s := &forge.TeamService{} +// _ = s.String() +func (s *TeamService) String() string { + if s == nil { + return "forge.TeamService{}" + } + return serviceString("forge.TeamService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the team service. +// +// Usage: +// +// s := &forge.TeamService{} +// _ = fmt.Sprintf("%#v", s) +func (s *TeamService) GoString() string { return s.String() } + +// String returns a safe summary of the user service. +// +// Usage: +// +// s := &forge.UserService{} +// _ = s.String() +func (s *UserService) String() string { + if s == nil { + return "forge.UserService{}" + } + return serviceString("forge.UserService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the user service. +// +// Usage: +// +// s := &forge.UserService{} +// _ = fmt.Sprintf("%#v", s) +func (s *UserService) GoString() string { return s.String() } + +// String returns a safe summary of the webhook service. +// +// Usage: +// +// s := &forge.WebhookService{} +// _ = s.String() +func (s *WebhookService) String() string { + if s == nil { + return "forge.WebhookService{}" + } + return serviceString("forge.WebhookService", "resource", &s.Resource) +} + +// GoString returns a safe Go-syntax summary of the webhook service. +// +// Usage: +// +// s := &forge.WebhookService{} +// _ = fmt.Sprintf("%#v", s) +func (s *WebhookService) GoString() string { return s.String() } + +// String returns a safe summary of the wiki service. +// +// Usage: +// +// s := &forge.WikiService{} +// _ = s.String() +func (s *WikiService) String() string { + if s == nil { + return "forge.WikiService{}" + } + return serviceString("forge.WikiService", "client", s.client) +} + +// GoString returns a safe Go-syntax summary of the wiki service. +// +// Usage: +// +// s := &forge.WikiService{} +// _ = fmt.Sprintf("%#v", s) +func (s *WikiService) GoString() string { return s.String() } diff --git a/stringer_nil_test.go b/stringer_nil_test.go new file mode 100644 index 0000000..f5d2ea1 --- /dev/null +++ b/stringer_nil_test.go @@ -0,0 +1,62 @@ +package forge + +import ( + "fmt" + "testing" +) + +func TestClient_String_NilSafe(t *testing.T) { + var c *Client + want := "forge.Client{}" + if got := c.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(c); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", c); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestForge_String_NilSafe(t *testing.T) { + var f *Forge + want := "forge.Forge{}" + if got := f.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(f); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", f); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestResource_String_NilSafe(t *testing.T) { + var r *Resource[int, struct{}, struct{}] + want := "forge.Resource{}" + if got := r.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(r); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", r); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} + +func TestAPIError_String_NilSafe(t *testing.T) { + var e *APIError + want := "forge.APIError{}" + if got := e.String(); got != want { + t.Fatalf("got String()=%q, want %q", got, want) + } + if got := fmt.Sprint(e); got != want { + t.Fatalf("got fmt.Sprint=%q, want %q", got, want) + } + if got := fmt.Sprintf("%#v", e); got != want { + t.Fatalf("got GoString=%q, want %q", got, want) + } +} diff --git a/teams.go b/teams.go index 32470ec..88e414c 100644 --- a/teams.go +++ b/teams.go @@ -2,13 +2,17 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" ) // TeamService handles team operations. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Teams.ListMembers(ctx, 42) type TeamService struct { Resource[types.Team, types.CreateTeamOption, types.EditTeamOption] } @@ -21,62 +25,104 @@ func newTeamService(c *Client) *TeamService { } } +// CreateOrgTeam creates a team within an organisation. +func (s *TeamService) CreateOrgTeam(ctx context.Context, org string, opts *types.CreateTeamOption) (*types.Team, error) { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + var out types.Team + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // ListMembers returns all members of a team. func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/teams/%d/members", teamID) + path := ResolvePath("/api/v1/teams/{id}/members", pathParams("id", int64String(teamID))) return ListAll[types.User](ctx, s.client, path, nil) } // IterMembers returns an iterator over all members of a team. func (s *TeamService) IterMembers(ctx context.Context, teamID int64) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/teams/%d/members", teamID) + path := ResolvePath("/api/v1/teams/{id}/members", pathParams("id", int64String(teamID))) return ListIter[types.User](ctx, s.client, path, nil) } // AddMember adds a user to a team. func (s *TeamService) AddMember(ctx context.Context, teamID int64, username string) error { - path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := ResolvePath("/api/v1/teams/{id}/members/{username}", pathParams("id", int64String(teamID), "username", username)) return s.client.Put(ctx, path, nil, nil) } +// GetMember returns a particular member of a team. +func (s *TeamService) GetMember(ctx context.Context, teamID int64, username string) (*types.User, error) { + path := ResolvePath("/api/v1/teams/{id}/members/{username}", pathParams("id", int64String(teamID), "username", username)) + var out types.User + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + // RemoveMember removes a user from a team. func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error { - path := fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := ResolvePath("/api/v1/teams/{id}/members/{username}", pathParams("id", int64String(teamID), "username", username)) return s.client.Delete(ctx, path) } // ListRepos returns all repositories managed by a team. func (s *TeamService) ListRepos(ctx context.Context, teamID int64) ([]types.Repository, error) { - path := fmt.Sprintf("/api/v1/teams/%d/repos", teamID) + path := ResolvePath("/api/v1/teams/{id}/repos", pathParams("id", int64String(teamID))) return ListAll[types.Repository](ctx, s.client, path, nil) } // IterRepos returns an iterator over all repositories managed by a team. func (s *TeamService) IterRepos(ctx context.Context, teamID int64) iter.Seq2[types.Repository, error] { - path := fmt.Sprintf("/api/v1/teams/%d/repos", teamID) + path := ResolvePath("/api/v1/teams/{id}/repos", pathParams("id", int64String(teamID))) return ListIter[types.Repository](ctx, s.client, path, nil) } // AddRepo adds a repository to a team. func (s *TeamService) AddRepo(ctx context.Context, teamID int64, org, repo string) error { - path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := ResolvePath("/api/v1/teams/{id}/repos/{org}/{repo}", pathParams("id", int64String(teamID), "org", org, "repo", repo)) return s.client.Put(ctx, path, nil, nil) } // RemoveRepo removes a repository from a team. func (s *TeamService) RemoveRepo(ctx context.Context, teamID int64, org, repo string) error { - path := fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := ResolvePath("/api/v1/teams/{id}/repos/{org}/{repo}", pathParams("id", int64String(teamID), "org", org, "repo", repo)) return s.client.Delete(ctx, path) } +// GetRepo returns a particular repository managed by a team. +func (s *TeamService) GetRepo(ctx context.Context, teamID int64, org, repo string) (*types.Repository, error) { + path := ResolvePath("/api/v1/teams/{id}/repos/{org}/{repo}", pathParams("id", int64String(teamID), "org", org, "repo", repo)) + var out types.Repository + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + // ListOrgTeams returns all teams in an organisation. func (s *TeamService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/teams", org) + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) return ListAll[types.Team](ctx, s.client, path, nil) } // IterOrgTeams returns an iterator over all teams in an organisation. func (s *TeamService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[types.Team, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/teams", org) + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) return ListIter[types.Team](ctx, s.client, path, nil) } + +// ListActivityFeeds returns a team's activity feed entries. +func (s *TeamService) ListActivityFeeds(ctx context.Context, teamID int64) ([]types.Activity, error) { + path := ResolvePath("/api/v1/teams/{id}/activities/feeds", pathParams("id", int64String(teamID))) + return ListAll[types.Activity](ctx, s.client, path, nil) +} + +// IterActivityFeeds returns an iterator over a team's activity feed entries. +func (s *TeamService) IterActivityFeeds(ctx context.Context, teamID int64) iter.Seq2[types.Activity, error] { + path := ResolvePath("/api/v1/teams/{id}/activities/feeds", pathParams("id", int64String(teamID))) + return ListIter[types.Activity](ctx, s.client, path, nil) +} diff --git a/teams_test.go b/teams_test.go index dc2d9f0..0572411 100644 --- a/teams_test.go +++ b/teams_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestTeamService_Good_Get(t *testing.T) { +func TestTeamService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -32,7 +32,38 @@ func TestTeamService_Good_Get(t *testing.T) { } } -func TestTeamService_Good_ListMembers(t *testing.T) { +func TestTeamService_CreateOrgTeam_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/teams" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateTeamOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Name != "platform" { + t.Errorf("got name=%q, want %q", opts.Name, "platform") + } + json.NewEncoder(w).Encode(types.Team{ID: 7, Name: opts.Name}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + team, err := f.Teams.CreateOrgTeam(context.Background(), "core", &types.CreateTeamOption{ + Name: "platform", + }) + if err != nil { + t.Fatal(err) + } + if team.ID != 7 || team.Name != "platform" { + t.Fatalf("got %#v", team) + } +} + +func TestTeamService_ListMembers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -61,7 +92,7 @@ func TestTeamService_Good_ListMembers(t *testing.T) { } } -func TestTeamService_Good_AddMember(t *testing.T) { +func TestTeamService_AddMember_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { t.Errorf("expected PUT, got %s", r.Method) @@ -79,3 +110,25 @@ func TestTeamService_Good_AddMember(t *testing.T) { t.Fatal(err) } } + +func TestTeamService_GetMember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/teams/42/members/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.User{ID: 1, UserName: "alice"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + member, err := f.Teams.GetMember(context.Background(), 42, "alice") + if err != nil { + t.Fatal(err) + } + if member.UserName != "alice" { + t.Errorf("got username=%q, want %q", member.UserName, "alice") + } +} diff --git a/types/action.go b/types/action.go index 1d08904..4f65057 100644 --- a/types/action.go +++ b/types/action.go @@ -4,58 +4,84 @@ package types import "time" - // ActionTask — ActionTask represents a ActionTask +// +// Usage: +// +// opts := ActionTask{DisplayTitle: "example"} type ActionTask struct { - CreatedAt time.Time `json:"created_at,omitempty"` - DisplayTitle string `json:"display_title,omitempty"` - Event string `json:"event,omitempty"` - HeadBranch string `json:"head_branch,omitempty"` - HeadSHA string `json:"head_sha,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - RunNumber int64 `json:"run_number,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + DisplayTitle string `json:"display_title,omitempty"` + Event string `json:"event,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + RunNumber int64 `json:"run_number,omitempty"` RunStartedAt time.Time `json:"run_started_at,omitempty"` - Status string `json:"status,omitempty"` - URL string `json:"url,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - WorkflowID string `json:"workflow_id,omitempty"` + Status string `json:"status,omitempty"` + URL string `json:"url,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` } // ActionTaskResponse — ActionTaskResponse returns a ActionTask +// +// Usage: +// +// opts := ActionTaskResponse{TotalCount: 1} type ActionTaskResponse struct { - Entries []*ActionTask `json:"workflow_runs,omitempty"` - TotalCount int64 `json:"total_count,omitempty"` + Entries []*ActionTask `json:"workflow_runs,omitempty"` + TotalCount int64 `json:"total_count,omitempty"` } // ActionVariable — ActionVariable return value of the query API +// +// Usage: +// +// opts := ActionVariable{Name: "example"} type ActionVariable struct { - Data string `json:"data,omitempty"` // the value of the variable - Name string `json:"name,omitempty"` // the name of the variable - OwnerID int64 `json:"owner_id,omitempty"` // the owner to which the variable belongs - RepoID int64 `json:"repo_id,omitempty"` // the repository to which the variable belongs + Data string `json:"data,omitempty"` // the value of the variable + Name string `json:"name,omitempty"` // the name of the variable + OwnerID int64 `json:"owner_id,omitempty"` // the owner to which the variable belongs + RepoID int64 `json:"repo_id,omitempty"` // the repository to which the variable belongs } // CreateVariableOption — CreateVariableOption the option when creating variable +// +// Usage: +// +// opts := CreateVariableOption{Value: "example"} type CreateVariableOption struct { Value string `json:"value"` // Value of the variable to create } // DispatchWorkflowOption — DispatchWorkflowOption options when dispatching a workflow +// +// Usage: +// +// opts := DispatchWorkflowOption{Ref: "main"} type DispatchWorkflowOption struct { - Inputs map[string]any `json:"inputs,omitempty"` // Input keys and values configured in the workflow file. - Ref string `json:"ref"` // Git reference for the workflow + Inputs map[string]string `json:"inputs,omitempty"` // Input keys and values configured in the workflow file. + Ref string `json:"ref"` // Git reference for the workflow } // Secret — Secret represents a secret +// +// Usage: +// +// opts := Secret{Name: "example"} type Secret struct { Created time.Time `json:"created_at,omitempty"` - Name string `json:"name,omitempty"` // the secret's name + Name string `json:"name,omitempty"` // the secret's name } // UpdateVariableOption — UpdateVariableOption the option when updating variable +// +// Usage: +// +// opts := UpdateVariableOption{Value: "example"} type UpdateVariableOption struct { - Name string `json:"name,omitempty"` // New name for the variable. If the field is empty, the variable name won't be updated. - Value string `json:"value"` // Value of the variable to update + Name string `json:"name,omitempty"` // New name for the variable. If the field is empty, the variable name won't be updated. + Value string `json:"value"` // Value of the variable to update } - diff --git a/types/activity.go b/types/activity.go index e897187..57fafd0 100644 --- a/types/activity.go +++ b/types/activity.go @@ -4,25 +4,30 @@ package types import "time" - +// Usage: +// +// opts := Activity{RefName: "main"} type Activity struct { - ActUser *User `json:"act_user,omitempty"` - ActUserID int64 `json:"act_user_id,omitempty"` - Comment *Comment `json:"comment,omitempty"` - CommentID int64 `json:"comment_id,omitempty"` - Content string `json:"content,omitempty"` - Created time.Time `json:"created,omitempty"` - ID int64 `json:"id,omitempty"` - IsPrivate bool `json:"is_private,omitempty"` - OpType string `json:"op_type,omitempty"` // the type of action - RefName string `json:"ref_name,omitempty"` - Repo *Repository `json:"repo,omitempty"` - RepoID int64 `json:"repo_id,omitempty"` - UserID int64 `json:"user_id,omitempty"` + ActUser *User `json:"act_user,omitempty"` + ActUserID int64 `json:"act_user_id,omitempty"` + Comment *Comment `json:"comment,omitempty"` + CommentID int64 `json:"comment_id,omitempty"` + Content string `json:"content,omitempty"` + Created time.Time `json:"created,omitempty"` + ID int64 `json:"id,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + OpType string `json:"op_type,omitempty"` // the type of action + RefName string `json:"ref_name,omitempty"` + Repo *Repository `json:"repo,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` } // ActivityPub — ActivityPub type +// +// Usage: +// +// opts := ActivityPub{Context: "example"} type ActivityPub struct { Context string `json:"@context,omitempty"` } - diff --git a/types/admin.go b/types/admin.go index 5090ff2..74899be 100644 --- a/types/admin.go +++ b/types/admin.go @@ -4,18 +4,24 @@ package types import "time" - // Cron — Cron represents a Cron task +// +// Usage: +// +// opts := Cron{Name: "example"} type Cron struct { - ExecTimes int64 `json:"exec_times,omitempty"` - Name string `json:"name,omitempty"` - Next time.Time `json:"next,omitempty"` - Prev time.Time `json:"prev,omitempty"` - Schedule string `json:"schedule,omitempty"` + ExecTimes int64 `json:"exec_times,omitempty"` + Name string `json:"name,omitempty"` + Next time.Time `json:"next,omitempty"` + Prev time.Time `json:"prev,omitempty"` + Schedule string `json:"schedule,omitempty"` } // RenameUserOption — RenameUserOption options when renaming a user +// +// Usage: +// +// opts := RenameUserOption{NewName: "example"} type RenameUserOption struct { NewName string `json:"new_username"` // New username for this user. This name cannot be in use yet by any other user. } - diff --git a/types/branch.go b/types/branch.go index 845a71e..23854de 100644 --- a/types/branch.go +++ b/types/branch.go @@ -4,116 +4,138 @@ package types import "time" - // Branch — Branch represents a repository branch +// +// Usage: +// +// opts := Branch{EffectiveBranchProtectionName: "main"} type Branch struct { - Commit *PayloadCommit `json:"commit,omitempty"` - EffectiveBranchProtectionName string `json:"effective_branch_protection_name,omitempty"` - EnableStatusCheck bool `json:"enable_status_check,omitempty"` - Name string `json:"name,omitempty"` - Protected bool `json:"protected,omitempty"` - RequiredApprovals int64 `json:"required_approvals,omitempty"` - StatusCheckContexts []string `json:"status_check_contexts,omitempty"` - UserCanMerge bool `json:"user_can_merge,omitempty"` - UserCanPush bool `json:"user_can_push,omitempty"` + Commit *PayloadCommit `json:"commit,omitempty"` + EffectiveBranchProtectionName string `json:"effective_branch_protection_name,omitempty"` + EnableStatusCheck bool `json:"enable_status_check,omitempty"` + Name string `json:"name,omitempty"` + Protected bool `json:"protected,omitempty"` + RequiredApprovals int64 `json:"required_approvals,omitempty"` + StatusCheckContexts []string `json:"status_check_contexts,omitempty"` + UserCanMerge bool `json:"user_can_merge,omitempty"` + UserCanPush bool `json:"user_can_push,omitempty"` } // BranchProtection — BranchProtection represents a branch protection for a repository +// +// Usage: +// +// opts := BranchProtection{BranchName: "main"} type BranchProtection struct { - ApplyToAdmins bool `json:"apply_to_admins,omitempty"` - ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` - ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` - BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` - BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` - BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` - BranchName string `json:"branch_name,omitempty"` // Deprecated: true - Created time.Time `json:"created_at,omitempty"` - DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` - EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` - EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` - EnablePush bool `json:"enable_push,omitempty"` - EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` - EnableStatusCheck bool `json:"enable_status_check,omitempty"` - IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` - MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` - MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` - ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` - PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` - PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` - PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` - RequireSignedCommits bool `json:"require_signed_commits,omitempty"` - RequiredApprovals int64 `json:"required_approvals,omitempty"` - RuleName string `json:"rule_name,omitempty"` - StatusCheckContexts []string `json:"status_check_contexts,omitempty"` - UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` + ApplyToAdmins bool `json:"apply_to_admins,omitempty"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` + BranchName string `json:"branch_name,omitempty"` // Deprecated: true + Created time.Time `json:"created_at,omitempty"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` + EnablePush bool `json:"enable_push,omitempty"` + EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` + EnableStatusCheck bool `json:"enable_status_check,omitempty"` + IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` + ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` + PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` + PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` + RequireSignedCommits bool `json:"require_signed_commits,omitempty"` + RequiredApprovals int64 `json:"required_approvals,omitempty"` + RuleName string `json:"rule_name,omitempty"` + StatusCheckContexts []string `json:"status_check_contexts,omitempty"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` } // CreateBranchProtectionOption — CreateBranchProtectionOption options for creating a branch protection +// +// Usage: +// +// opts := CreateBranchProtectionOption{BranchName: "main"} type CreateBranchProtectionOption struct { - ApplyToAdmins bool `json:"apply_to_admins,omitempty"` - ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` - ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` - BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` - BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` - BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` - BranchName string `json:"branch_name,omitempty"` // Deprecated: true - DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` - EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` - EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` - EnablePush bool `json:"enable_push,omitempty"` - EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` - EnableStatusCheck bool `json:"enable_status_check,omitempty"` - IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` - MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` - MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` - ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` - PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` - PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` - PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` - RequireSignedCommits bool `json:"require_signed_commits,omitempty"` - RequiredApprovals int64 `json:"required_approvals,omitempty"` - RuleName string `json:"rule_name,omitempty"` - StatusCheckContexts []string `json:"status_check_contexts,omitempty"` - UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` + ApplyToAdmins bool `json:"apply_to_admins,omitempty"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` + BranchName string `json:"branch_name,omitempty"` // Deprecated: true + DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` + EnablePush bool `json:"enable_push,omitempty"` + EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` + EnableStatusCheck bool `json:"enable_status_check,omitempty"` + IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` + ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` + PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` + PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` + RequireSignedCommits bool `json:"require_signed_commits,omitempty"` + RequiredApprovals int64 `json:"required_approvals,omitempty"` + RuleName string `json:"rule_name,omitempty"` + StatusCheckContexts []string `json:"status_check_contexts,omitempty"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` } // CreateBranchRepoOption — CreateBranchRepoOption options when creating a branch in a repository +// +// Usage: +// +// opts := CreateBranchRepoOption{BranchName: "main"} type CreateBranchRepoOption struct { - BranchName string `json:"new_branch_name"` // Name of the branch to create + BranchName string `json:"new_branch_name"` // Name of the branch to create OldBranchName string `json:"old_branch_name,omitempty"` // Deprecated: true Name of the old branch to create from - OldRefName string `json:"old_ref_name,omitempty"` // Name of the old branch/tag/commit to create from + OldRefName string `json:"old_ref_name,omitempty"` // Name of the old branch/tag/commit to create from } // EditBranchProtectionOption — EditBranchProtectionOption options for editing a branch protection +// +// Usage: +// +// opts := EditBranchProtectionOption{ApprovalsWhitelistTeams: []string{"example"}} type EditBranchProtectionOption struct { - ApplyToAdmins bool `json:"apply_to_admins,omitempty"` - ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` - ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` - BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` - BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` - BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` - DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` - EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` - EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` - EnablePush bool `json:"enable_push,omitempty"` - EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` - EnableStatusCheck bool `json:"enable_status_check,omitempty"` - IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` - MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` - MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` - ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` - PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` - PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` - PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` - RequireSignedCommits bool `json:"require_signed_commits,omitempty"` - RequiredApprovals int64 `json:"required_approvals,omitempty"` - StatusCheckContexts []string `json:"status_check_contexts,omitempty"` - UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` + ApplyToAdmins bool `json:"apply_to_admins,omitempty"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams,omitempty"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username,omitempty"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitempty"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitempty"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitempty"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitempty"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitempty"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitempty"` + EnablePush bool `json:"enable_push,omitempty"` + EnablePushWhitelist bool `json:"enable_push_whitelist,omitempty"` + EnableStatusCheck bool `json:"enable_status_check,omitempty"` + IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitempty"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams,omitempty"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames,omitempty"` + ProtectedFilePatterns string `json:"protected_file_patterns,omitempty"` + PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys,omitempty"` + PushWhitelistTeams []string `json:"push_whitelist_teams,omitempty"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames,omitempty"` + RequireSignedCommits bool `json:"require_signed_commits,omitempty"` + RequiredApprovals int64 `json:"required_approvals,omitempty"` + StatusCheckContexts []string `json:"status_check_contexts,omitempty"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitempty"` } // UpdateBranchRepoOption — UpdateBranchRepoOption options when updating a branch in a repository +// +// Usage: +// +// opts := UpdateBranchRepoOption{Name: "example"} type UpdateBranchRepoOption struct { Name string `json:"name"` // New branch name } - diff --git a/types/comment.go b/types/comment.go index b951832..c5eebb6 100644 --- a/types/comment.go +++ b/types/comment.go @@ -4,19 +4,21 @@ package types import "time" - // Comment — Comment represents a comment on a commit or issue +// +// Usage: +// +// opts := Comment{Body: "example"} type Comment struct { - Attachments []*Attachment `json:"assets,omitempty"` - Body string `json:"body,omitempty"` - Created time.Time `json:"created_at,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - IssueURL string `json:"issue_url,omitempty"` - OriginalAuthor string `json:"original_author,omitempty"` - OriginalAuthorID int64 `json:"original_author_id,omitempty"` - PRURL string `json:"pull_request_url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Attachments []*Attachment `json:"assets,omitempty"` + Body string `json:"body,omitempty"` + Created time.Time `json:"created_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + IssueURL string `json:"issue_url,omitempty"` + OriginalAuthor string `json:"original_author,omitempty"` + OriginalAuthorID int64 `json:"original_author_id,omitempty"` + PRURL string `json:"pull_request_url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } - diff --git a/types/commit.go b/types/commit.go index f670bfc..8c899e2 100644 --- a/types/commit.go +++ b/types/commit.go @@ -4,65 +4,91 @@ package types import "time" - +// Usage: +// +// opts := Commit{HTMLURL: "https://example.com"} type Commit struct { - Author *User `json:"author,omitempty"` - Commit *RepoCommit `json:"commit,omitempty"` - Committer *User `json:"committer,omitempty"` - Created time.Time `json:"created,omitempty"` - Files []*CommitAffectedFiles `json:"files,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - Parents []*CommitMeta `json:"parents,omitempty"` - SHA string `json:"sha,omitempty"` - Stats *CommitStats `json:"stats,omitempty"` - URL string `json:"url,omitempty"` + Author *User `json:"author,omitempty"` + Commit *RepoCommit `json:"commit,omitempty"` + Committer *User `json:"committer,omitempty"` + Created time.Time `json:"created,omitempty"` + Files []*CommitAffectedFiles `json:"files,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Parents []*CommitMeta `json:"parents,omitempty"` + SHA string `json:"sha,omitempty"` + Stats *CommitStats `json:"stats,omitempty"` + URL string `json:"url,omitempty"` } // CommitAffectedFiles — CommitAffectedFiles store information about files affected by the commit +// +// Usage: +// +// opts := CommitAffectedFiles{Filename: "example"} type CommitAffectedFiles struct { Filename string `json:"filename,omitempty"` - Status string `json:"status,omitempty"` + Status string `json:"status,omitempty"` } // CommitDateOptions — CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE +// +// Usage: +// +// opts := CommitDateOptions{Author: time.Now()} type CommitDateOptions struct { - Author time.Time `json:"author,omitempty"` + Author time.Time `json:"author,omitempty"` Committer time.Time `json:"committer,omitempty"` } +// Usage: +// +// opts := CommitMeta{SHA: "example"} type CommitMeta struct { Created time.Time `json:"created,omitempty"` - SHA string `json:"sha,omitempty"` - URL string `json:"url,omitempty"` + SHA string `json:"sha,omitempty"` + URL string `json:"url,omitempty"` } // CommitStats — CommitStats is statistics for a RepoCommit +// +// Usage: +// +// opts := CommitStats{Additions: 1} type CommitStats struct { Additions int64 `json:"additions,omitempty"` Deletions int64 `json:"deletions,omitempty"` - Total int64 `json:"total,omitempty"` + Total int64 `json:"total,omitempty"` } // CommitStatus — CommitStatus holds a single status of a single Commit +// +// Usage: +// +// opts := CommitStatus{Description: "example"} type CommitStatus struct { - Context string `json:"context,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Creator *User `json:"creator,omitempty"` - Description string `json:"description,omitempty"` - ID int64 `json:"id,omitempty"` - Status *CommitStatusState `json:"status,omitempty"` - TargetURL string `json:"target_url,omitempty"` - URL string `json:"url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` + Context string `json:"context,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Creator *User `json:"creator,omitempty"` + Description string `json:"description,omitempty"` + ID int64 `json:"id,omitempty"` + Status CommitStatusState `json:"status,omitempty"` + TargetURL string `json:"target_url,omitempty"` + URL string `json:"url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` } // CommitStatusState — CommitStatusState holds the state of a CommitStatus It can be "pending", "success", "error" and "failure" -// CommitStatusState has no fields in the swagger spec. -type CommitStatusState struct{} +// +// Usage: +// +// opts := CommitStatusState("example") +type CommitStatusState string +// Usage: +// +// opts := CommitUser{Name: "example"} type CommitUser struct { - Date string `json:"date,omitempty"` + Date string `json:"date,omitempty"` Email string `json:"email,omitempty"` - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` } - diff --git a/types/common.go b/types/common.go index df9223c..fe42c98 100644 --- a/types/common.go +++ b/types/common.go @@ -4,37 +4,53 @@ package types import "time" - // Attachment — Attachment a generic attachment +// +// Usage: +// +// opts := Attachment{Name: "example"} type Attachment struct { - Created time.Time `json:"created_at,omitempty"` - DownloadCount int64 `json:"download_count,omitempty"` - DownloadURL string `json:"browser_download_url,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Size int64 `json:"size,omitempty"` - Type string `json:"type,omitempty"` - UUID string `json:"uuid,omitempty"` + Created time.Time `json:"created_at,omitempty"` + DownloadCount int64 `json:"download_count,omitempty"` + DownloadURL string `json:"browser_download_url,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Size int64 `json:"size,omitempty"` + Type string `json:"type,omitempty"` + UUID string `json:"uuid,omitempty"` } // EditAttachmentOptions — EditAttachmentOptions options for editing attachments +// +// Usage: +// +// opts := EditAttachmentOptions{Name: "example"} type EditAttachmentOptions struct { DownloadURL string `json:"browser_download_url,omitempty"` // (Can only be set if existing attachment is of external type) - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` } // Permission — Permission represents a set of permissions +// +// Usage: +// +// opts := Permission{Admin: true} type Permission struct { Admin bool `json:"admin,omitempty"` - Pull bool `json:"pull,omitempty"` - Push bool `json:"push,omitempty"` + Pull bool `json:"pull,omitempty"` + Push bool `json:"push,omitempty"` } // StateType — StateType issue state type -// StateType has no fields in the swagger spec. -type StateType struct{} +// +// Usage: +// +// opts := StateType("example") +type StateType string // TimeStamp — TimeStamp defines a timestamp -// TimeStamp has no fields in the swagger spec. -type TimeStamp struct{} - +// +// Usage: +// +// opts := TimeStamp(1) +type TimeStamp int64 diff --git a/types/content.go b/types/content.go index 462eedc..8693d8f 100644 --- a/types/content.go +++ b/types/content.go @@ -4,101 +4,134 @@ package types import "time" - // ContentsResponse — ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content +// +// Usage: +// +// opts := ContentsResponse{Name: "example"} type ContentsResponse struct { - Content string `json:"content,omitempty"` // `content` is populated when `type` is `file`, otherwise null - DownloadURL string `json:"download_url,omitempty"` - Encoding string `json:"encoding,omitempty"` // `encoding` is populated when `type` is `file`, otherwise null - GitURL string `json:"git_url,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - LastCommitSHA string `json:"last_commit_sha,omitempty"` - Links *FileLinksResponse `json:"_links,omitempty"` - Name string `json:"name,omitempty"` - Path string `json:"path,omitempty"` - SHA string `json:"sha,omitempty"` - Size int64 `json:"size,omitempty"` - SubmoduleGitURL string `json:"submodule_git_url,omitempty"` // `submodule_git_url` is populated when `type` is `submodule`, otherwise null - Target string `json:"target,omitempty"` // `target` is populated when `type` is `symlink`, otherwise null - Type string `json:"type,omitempty"` // `type` will be `file`, `dir`, `symlink`, or `submodule` - URL string `json:"url,omitempty"` + Content string `json:"content,omitempty"` // `content` is populated when `type` is `file`, otherwise null + DownloadURL string `json:"download_url,omitempty"` + Encoding string `json:"encoding,omitempty"` // `encoding` is populated when `type` is `file`, otherwise null + GitURL string `json:"git_url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + LastCommitSHA string `json:"last_commit_sha,omitempty"` + Links *FileLinksResponse `json:"_links,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size,omitempty"` + SubmoduleGitURL string `json:"submodule_git_url,omitempty"` // `submodule_git_url` is populated when `type` is `submodule`, otherwise null + Target string `json:"target,omitempty"` // `target` is populated when `type` is `symlink`, otherwise null + Type string `json:"type,omitempty"` // `type` will be `file`, `dir`, `symlink`, or `submodule` + URL string `json:"url,omitempty"` } // CreateFileOptions — CreateFileOptions options for creating files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +// +// Usage: +// +// opts := CreateFileOptions{ContentBase64: "example"} type CreateFileOptions struct { - Author *Identity `json:"author,omitempty"` - BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used - Committer *Identity `json:"committer,omitempty"` - ContentBase64 string `json:"content"` // content must be base64 encoded - Dates *CommitDateOptions `json:"dates,omitempty"` - Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used - NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file - Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. + Author *Identity `json:"author,omitempty"` + BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used + Committer *Identity `json:"committer,omitempty"` + ContentBase64 string `json:"content"` // content must be base64 encoded + Dates *CommitDateOptions `json:"dates,omitempty"` + Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used + NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file + Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. } // DeleteFileOptions — DeleteFileOptions options for deleting files (used for other File structs below) Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +// +// Usage: +// +// opts := DeleteFileOptions{SHA: "example"} type DeleteFileOptions struct { - Author *Identity `json:"author,omitempty"` - BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used - Committer *Identity `json:"committer,omitempty"` - Dates *CommitDateOptions `json:"dates,omitempty"` - Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used - NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file - SHA string `json:"sha"` // sha is the SHA for the file that already exists - Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. + Author *Identity `json:"author,omitempty"` + BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used + Committer *Identity `json:"committer,omitempty"` + Dates *CommitDateOptions `json:"dates,omitempty"` + Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used + NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file + SHA string `json:"sha"` // sha is the SHA for the file that already exists + Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. } +// Usage: +// +// opts := FileCommitResponse{HTMLURL: "https://example.com"} type FileCommitResponse struct { - Author *CommitUser `json:"author,omitempty"` - Committer *CommitUser `json:"committer,omitempty"` - Created time.Time `json:"created,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - Message string `json:"message,omitempty"` - Parents []*CommitMeta `json:"parents,omitempty"` - SHA string `json:"sha,omitempty"` - Tree *CommitMeta `json:"tree,omitempty"` - URL string `json:"url,omitempty"` + Author *CommitUser `json:"author,omitempty"` + Committer *CommitUser `json:"committer,omitempty"` + Created time.Time `json:"created,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Message string `json:"message,omitempty"` + Parents []*CommitMeta `json:"parents,omitempty"` + SHA string `json:"sha,omitempty"` + Tree *CommitMeta `json:"tree,omitempty"` + URL string `json:"url,omitempty"` } // FileDeleteResponse — FileDeleteResponse contains information about a repo's file that was deleted +// +// Usage: +// +// opts := FileDeleteResponse{Commit: &FileCommitResponse{}} type FileDeleteResponse struct { - Commit *FileCommitResponse `json:"commit,omitempty"` - Content any `json:"content,omitempty"` + Commit *FileCommitResponse `json:"commit,omitempty"` + Content any `json:"content,omitempty"` Verification *PayloadCommitVerification `json:"verification,omitempty"` } // FileLinksResponse — FileLinksResponse contains the links for a repo's file +// +// Usage: +// +// opts := FileLinksResponse{GitURL: "https://example.com"} type FileLinksResponse struct { - GitURL string `json:"git,omitempty"` + GitURL string `json:"git,omitempty"` HTMLURL string `json:"html,omitempty"` - Self string `json:"self,omitempty"` + Self string `json:"self,omitempty"` } // FileResponse — FileResponse contains information about a repo's file +// +// Usage: +// +// opts := FileResponse{Commit: &FileCommitResponse{}} type FileResponse struct { - Commit *FileCommitResponse `json:"commit,omitempty"` - Content *ContentsResponse `json:"content,omitempty"` + Commit *FileCommitResponse `json:"commit,omitempty"` + Content *ContentsResponse `json:"content,omitempty"` Verification *PayloadCommitVerification `json:"verification,omitempty"` } // FilesResponse — FilesResponse contains information about multiple files from a repo +// +// Usage: +// +// opts := FilesResponse{Files: {}} type FilesResponse struct { - Commit *FileCommitResponse `json:"commit,omitempty"` - Files []*ContentsResponse `json:"files,omitempty"` + Commit *FileCommitResponse `json:"commit,omitempty"` + Files []*ContentsResponse `json:"files,omitempty"` Verification *PayloadCommitVerification `json:"verification,omitempty"` } // UpdateFileOptions — UpdateFileOptions options for updating files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +// +// Usage: +// +// opts := UpdateFileOptions{ContentBase64: "example"} type UpdateFileOptions struct { - Author *Identity `json:"author,omitempty"` - BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used - Committer *Identity `json:"committer,omitempty"` - ContentBase64 string `json:"content"` // content must be base64 encoded - Dates *CommitDateOptions `json:"dates,omitempty"` - FromPath string `json:"from_path,omitempty"` // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL - Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used - NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file - SHA string `json:"sha"` // sha is the SHA for the file that already exists - Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. + Author *Identity `json:"author,omitempty"` + BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used + Committer *Identity `json:"committer,omitempty"` + ContentBase64 string `json:"content"` // content must be base64 encoded + Dates *CommitDateOptions `json:"dates,omitempty"` + FromPath string `json:"from_path,omitempty"` // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL + Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used + NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file + SHA string `json:"sha"` // sha is the SHA for the file that already exists + Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. } - diff --git a/types/error.go b/types/error.go index 7f034c3..355b9fc 100644 --- a/types/error.go +++ b/types/error.go @@ -2,41 +2,61 @@ package types - // APIError — APIError is an api error with a message +// +// Usage: +// +// opts := APIError{Message: "example"} type APIError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := APIForbiddenError{Message: "example"} type APIForbiddenError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := APIInvalidTopicsError{InvalidTopics: []string{"example"}} type APIInvalidTopicsError struct { InvalidTopics []string `json:"invalidTopics,omitempty"` - Message string `json:"message,omitempty"` + Message string `json:"message,omitempty"` } +// Usage: +// +// opts := APINotFound{Errors: []string{"example"}} type APINotFound struct { - Errors []string `json:"errors,omitempty"` - Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + Errors []string `json:"errors,omitempty"` + Message string `json:"message,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := APIRepoArchivedError{Message: "example"} type APIRepoArchivedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := APIUnauthorizedError{Message: "example"} type APIUnauthorizedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := APIValidationError{Message: "example"} type APIValidationError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } - diff --git a/types/federation.go b/types/federation.go index 6f6574d..2b7dc72 100644 --- a/types/federation.go +++ b/types/federation.go @@ -2,43 +2,61 @@ package types - // NodeInfo — NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks +// +// Usage: +// +// opts := NodeInfo{Protocols: []string{"example"}} type NodeInfo struct { - Metadata map[string]any `json:"metadata,omitempty"` - OpenRegistrations bool `json:"openRegistrations,omitempty"` - Protocols []string `json:"protocols,omitempty"` - Services *NodeInfoServices `json:"services,omitempty"` - Software *NodeInfoSoftware `json:"software,omitempty"` - Usage *NodeInfoUsage `json:"usage,omitempty"` - Version string `json:"version,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + OpenRegistrations bool `json:"openRegistrations,omitempty"` + Protocols []string `json:"protocols,omitempty"` + Services *NodeInfoServices `json:"services,omitempty"` + Software *NodeInfoSoftware `json:"software,omitempty"` + Usage *NodeInfoUsage `json:"usage,omitempty"` + Version string `json:"version,omitempty"` } // NodeInfoServices — NodeInfoServices contains the third party sites this server can connect to via their application API +// +// Usage: +// +// opts := NodeInfoServices{Inbound: []string{"example"}} type NodeInfoServices struct { - Inbound []string `json:"inbound,omitempty"` + Inbound []string `json:"inbound,omitempty"` Outbound []string `json:"outbound,omitempty"` } // NodeInfoSoftware — NodeInfoSoftware contains Metadata about server software in use +// +// Usage: +// +// opts := NodeInfoSoftware{Name: "example"} type NodeInfoSoftware struct { - Homepage string `json:"homepage,omitempty"` - Name string `json:"name,omitempty"` + Homepage string `json:"homepage,omitempty"` + Name string `json:"name,omitempty"` Repository string `json:"repository,omitempty"` - Version string `json:"version,omitempty"` + Version string `json:"version,omitempty"` } // NodeInfoUsage — NodeInfoUsage contains usage statistics for this server +// +// Usage: +// +// opts := NodeInfoUsage{LocalComments: 1} type NodeInfoUsage struct { - LocalComments int64 `json:"localComments,omitempty"` - LocalPosts int64 `json:"localPosts,omitempty"` - Users *NodeInfoUsageUsers `json:"users,omitempty"` + LocalComments int64 `json:"localComments,omitempty"` + LocalPosts int64 `json:"localPosts,omitempty"` + Users *NodeInfoUsageUsers `json:"users,omitempty"` } // NodeInfoUsageUsers — NodeInfoUsageUsers contains statistics about the users of this server +// +// Usage: +// +// opts := NodeInfoUsageUsers{ActiveHalfyear: 1} type NodeInfoUsageUsers struct { ActiveHalfyear int64 `json:"activeHalfyear,omitempty"` - ActiveMonth int64 `json:"activeMonth,omitempty"` - Total int64 `json:"total,omitempty"` + ActiveMonth int64 `json:"activeMonth,omitempty"` + Total int64 `json:"total,omitempty"` } - diff --git a/types/git.go b/types/git.go index ec79f5f..d9b153c 100644 --- a/types/git.go +++ b/types/git.go @@ -2,93 +2,133 @@ package types - // AnnotatedTag — AnnotatedTag represents an annotated tag +// +// Usage: +// +// opts := AnnotatedTag{Message: "example"} type AnnotatedTag struct { - ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count,omitempty"` - Message string `json:"message,omitempty"` - Object *AnnotatedTagObject `json:"object,omitempty"` - SHA string `json:"sha,omitempty"` - Tag string `json:"tag,omitempty"` - Tagger *CommitUser `json:"tagger,omitempty"` - URL string `json:"url,omitempty"` - Verification *PayloadCommitVerification `json:"verification,omitempty"` + ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count,omitempty"` + Message string `json:"message,omitempty"` + Object *AnnotatedTagObject `json:"object,omitempty"` + SHA string `json:"sha,omitempty"` + Tag string `json:"tag,omitempty"` + Tagger *CommitUser `json:"tagger,omitempty"` + URL string `json:"url,omitempty"` + Verification *PayloadCommitVerification `json:"verification,omitempty"` } // AnnotatedTagObject — AnnotatedTagObject contains meta information of the tag object +// +// Usage: +// +// opts := AnnotatedTagObject{SHA: "example"} type AnnotatedTagObject struct { - SHA string `json:"sha,omitempty"` + SHA string `json:"sha,omitempty"` Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } // ChangedFile — ChangedFile store information about files affected by the pull request +// +// Usage: +// +// opts := ChangedFile{ContentsURL: "https://example.com"} type ChangedFile struct { - Additions int64 `json:"additions,omitempty"` - Changes int64 `json:"changes,omitempty"` - ContentsURL string `json:"contents_url,omitempty"` - Deletions int64 `json:"deletions,omitempty"` - Filename string `json:"filename,omitempty"` - HTMLURL string `json:"html_url,omitempty"` + Additions int64 `json:"additions,omitempty"` + Changes int64 `json:"changes,omitempty"` + ContentsURL string `json:"contents_url,omitempty"` + Deletions int64 `json:"deletions,omitempty"` + Filename string `json:"filename,omitempty"` + HTMLURL string `json:"html_url,omitempty"` PreviousFilename string `json:"previous_filename,omitempty"` - RawURL string `json:"raw_url,omitempty"` - Status string `json:"status,omitempty"` + RawURL string `json:"raw_url,omitempty"` + Status string `json:"status,omitempty"` } // EditGitHookOption — EditGitHookOption options when modifying one Git hook +// +// Usage: +// +// opts := EditGitHookOption{Content: "example"} type EditGitHookOption struct { Content string `json:"content,omitempty"` } // GitBlobResponse — GitBlobResponse represents a git blob +// +// Usage: +// +// opts := GitBlobResponse{Content: "example"} type GitBlobResponse struct { - Content string `json:"content,omitempty"` + Content string `json:"content,omitempty"` Encoding string `json:"encoding,omitempty"` - SHA string `json:"sha,omitempty"` - Size int64 `json:"size,omitempty"` - URL string `json:"url,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size,omitempty"` + URL string `json:"url,omitempty"` } // GitEntry — GitEntry represents a git tree +// +// Usage: +// +// opts := GitEntry{Mode: "example"} type GitEntry struct { Mode string `json:"mode,omitempty"` Path string `json:"path,omitempty"` - SHA string `json:"sha,omitempty"` - Size int64 `json:"size,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size,omitempty"` Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } // GitHook — GitHook represents a Git repository hook +// +// Usage: +// +// opts := GitHook{Name: "example"} type GitHook struct { - Content string `json:"content,omitempty"` - IsActive bool `json:"is_active,omitempty"` - Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + IsActive bool `json:"is_active,omitempty"` + Name string `json:"name,omitempty"` } +// Usage: +// +// opts := GitObject{SHA: "example"} type GitObject struct { - SHA string `json:"sha,omitempty"` + SHA string `json:"sha,omitempty"` Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } // GitTreeResponse — GitTreeResponse returns a git tree +// +// Usage: +// +// opts := GitTreeResponse{SHA: "example"} type GitTreeResponse struct { - Entries []*GitEntry `json:"tree,omitempty"` - Page int64 `json:"page,omitempty"` - SHA string `json:"sha,omitempty"` - TotalCount int64 `json:"total_count,omitempty"` - Truncated bool `json:"truncated,omitempty"` - URL string `json:"url,omitempty"` + Entries []*GitEntry `json:"tree,omitempty"` + Page int64 `json:"page,omitempty"` + SHA string `json:"sha,omitempty"` + TotalCount int64 `json:"total_count,omitempty"` + Truncated bool `json:"truncated,omitempty"` + URL string `json:"url,omitempty"` } // Note — Note contains information related to a git note +// +// Usage: +// +// opts := Note{Message: "example"} type Note struct { - Commit *Commit `json:"commit,omitempty"` - Message string `json:"message,omitempty"` + Commit *Commit `json:"commit,omitempty"` + Message string `json:"message,omitempty"` } +// Usage: +// +// opts := NoteOptions{Message: "example"} type NoteOptions struct { Message string `json:"message,omitempty"` } - diff --git a/types/hook.go b/types/hook.go index 26ba1bd..27015a8 100644 --- a/types/hook.go +++ b/types/hook.go @@ -4,66 +4,87 @@ package types import "time" - // CreateHookOption — CreateHookOption options when create a hook +// +// Usage: +// +// opts := CreateHookOption{Type: "example"} type CreateHookOption struct { - Active bool `json:"active,omitempty"` - AuthorizationHeader string `json:"authorization_header,omitempty"` - BranchFilter string `json:"branch_filter,omitempty"` - Config *CreateHookOptionConfig `json:"config"` - Events []string `json:"events,omitempty"` - Type string `json:"type"` + Active bool `json:"active,omitempty"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + BranchFilter string `json:"branch_filter,omitempty"` + Config *CreateHookOptionConfig `json:"config"` + Events []string `json:"events,omitempty"` + Type string `json:"type"` } // CreateHookOptionConfig — CreateHookOptionConfig has all config options in it required are "content_type" and "url" Required -// CreateHookOptionConfig has no fields in the swagger spec. -type CreateHookOptionConfig struct{} +// +// Usage: +// +// opts := CreateHookOptionConfig(map[string]any{"key": "value"}) +type CreateHookOptionConfig map[string]any // EditHookOption — EditHookOption options when modify one hook +// +// Usage: +// +// opts := EditHookOption{AuthorizationHeader: "example"} type EditHookOption struct { - Active bool `json:"active,omitempty"` - AuthorizationHeader string `json:"authorization_header,omitempty"` - BranchFilter string `json:"branch_filter,omitempty"` - Config map[string]any `json:"config,omitempty"` - Events []string `json:"events,omitempty"` + Active bool `json:"active,omitempty"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + BranchFilter string `json:"branch_filter,omitempty"` + Config map[string]string `json:"config,omitempty"` + Events []string `json:"events,omitempty"` } // Hook — Hook a hook is a web hook when one repository changed +// +// Usage: +// +// opts := Hook{AuthorizationHeader: "example"} type Hook struct { - Active bool `json:"active,omitempty"` - AuthorizationHeader string `json:"authorization_header,omitempty"` - BranchFilter string `json:"branch_filter,omitempty"` - Config map[string]any `json:"config,omitempty"` // Deprecated: use Metadata instead - ContentType string `json:"content_type,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Events []string `json:"events,omitempty"` - ID int64 `json:"id,omitempty"` - Metadata any `json:"metadata,omitempty"` - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` + Active bool `json:"active,omitempty"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + BranchFilter string `json:"branch_filter,omitempty"` + Config map[string]string `json:"config,omitempty"` // Deprecated: use Metadata instead + ContentType string `json:"content_type,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Events []string `json:"events,omitempty"` + ID int64 `json:"id,omitempty"` + Metadata any `json:"metadata,omitempty"` + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` } // PayloadCommit — PayloadCommit represents a commit +// +// Usage: +// +// opts := PayloadCommit{Added: []string{"example"}} type PayloadCommit struct { - Added []string `json:"added,omitempty"` - Author *PayloadUser `json:"author,omitempty"` - Committer *PayloadUser `json:"committer,omitempty"` - ID string `json:"id,omitempty"` // sha1 hash of the commit - Message string `json:"message,omitempty"` - Modified []string `json:"modified,omitempty"` - Removed []string `json:"removed,omitempty"` - Timestamp time.Time `json:"timestamp,omitempty"` - URL string `json:"url,omitempty"` + Added []string `json:"added,omitempty"` + Author *PayloadUser `json:"author,omitempty"` + Committer *PayloadUser `json:"committer,omitempty"` + ID string `json:"id,omitempty"` // sha1 hash of the commit + Message string `json:"message,omitempty"` + Modified []string `json:"modified,omitempty"` + Removed []string `json:"removed,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` + URL string `json:"url,omitempty"` Verification *PayloadCommitVerification `json:"verification,omitempty"` } // PayloadCommitVerification — PayloadCommitVerification represents the GPG verification of a commit +// +// Usage: +// +// opts := PayloadCommitVerification{Payload: "example"} type PayloadCommitVerification struct { - Payload string `json:"payload,omitempty"` - Reason string `json:"reason,omitempty"` - Signature string `json:"signature,omitempty"` - Signer *PayloadUser `json:"signer,omitempty"` - Verified bool `json:"verified,omitempty"` + Payload string `json:"payload,omitempty"` + Reason string `json:"reason,omitempty"` + Signature string `json:"signature,omitempty"` + Signer *PayloadUser `json:"signer,omitempty"` + Verified bool `json:"verified,omitempty"` } - diff --git a/types/issue.go b/types/issue.go index 841f6ae..b04b9e4 100644 --- a/types/issue.go +++ b/types/issue.go @@ -4,142 +4,200 @@ package types import "time" - // CreateIssueCommentOption — CreateIssueCommentOption options for creating a comment on an issue +// +// Usage: +// +// opts := CreateIssueCommentOption{Body: "example"} type CreateIssueCommentOption struct { - Body string `json:"body"` + Body string `json:"body"` Updated time.Time `json:"updated_at,omitempty"` } // CreateIssueOption — CreateIssueOption options to create one issue +// +// Usage: +// +// opts := CreateIssueOption{Title: "example"} type CreateIssueOption struct { - Assignee string `json:"assignee,omitempty"` // deprecated - Assignees []string `json:"assignees,omitempty"` - Body string `json:"body,omitempty"` - Closed bool `json:"closed,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` // list of label ids - Milestone int64 `json:"milestone,omitempty"` // milestone id - Ref string `json:"ref,omitempty"` - Title string `json:"title"` + Assignee string `json:"assignee,omitempty"` // deprecated + Assignees []string `json:"assignees,omitempty"` + Body string `json:"body,omitempty"` + Closed bool `json:"closed,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + Labels []int64 `json:"labels,omitempty"` // list of label ids + Milestone int64 `json:"milestone,omitempty"` // milestone id + Ref string `json:"ref,omitempty"` + Title string `json:"title"` } // EditDeadlineOption — EditDeadlineOption options for creating a deadline +// +// Usage: +// +// opts := EditDeadlineOption{Deadline: time.Now()} type EditDeadlineOption struct { Deadline time.Time `json:"due_date"` } // EditIssueCommentOption — EditIssueCommentOption options for editing a comment +// +// Usage: +// +// opts := EditIssueCommentOption{Body: "example"} type EditIssueCommentOption struct { - Body string `json:"body"` + Body string `json:"body"` Updated time.Time `json:"updated_at,omitempty"` } // EditIssueOption — EditIssueOption options for editing an issue +// +// Usage: +// +// opts := EditIssueOption{Body: "example"} type EditIssueOption struct { - Assignee string `json:"assignee,omitempty"` // deprecated - Assignees []string `json:"assignees,omitempty"` - Body string `json:"body,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - Milestone int64 `json:"milestone,omitempty"` - Ref string `json:"ref,omitempty"` - RemoveDeadline bool `json:"unset_due_date,omitempty"` - State string `json:"state,omitempty"` - Title string `json:"title,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` + Assignee string `json:"assignee,omitempty"` // deprecated + Assignees []string `json:"assignees,omitempty"` + Body string `json:"body,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Ref string `json:"ref,omitempty"` + RemoveDeadline bool `json:"unset_due_date,omitempty"` + State string `json:"state,omitempty"` + Title string `json:"title,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` } // Issue — Issue represents an issue in a repository +// +// Usage: +// +// opts := Issue{Body: "example"} type Issue struct { - Assignee *User `json:"assignee,omitempty"` - Assignees []*User `json:"assignees,omitempty"` - Attachments []*Attachment `json:"assets,omitempty"` - Body string `json:"body,omitempty"` - Closed time.Time `json:"closed_at,omitempty"` - Comments int64 `json:"comments,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - Index int64 `json:"number,omitempty"` - IsLocked bool `json:"is_locked,omitempty"` - Labels []*Label `json:"labels,omitempty"` - Milestone *Milestone `json:"milestone,omitempty"` - OriginalAuthor string `json:"original_author,omitempty"` - OriginalAuthorID int64 `json:"original_author_id,omitempty"` - PinOrder int64 `json:"pin_order,omitempty"` - PullRequest *PullRequestMeta `json:"pull_request,omitempty"` - Ref string `json:"ref,omitempty"` - Repository *RepositoryMeta `json:"repository,omitempty"` - State *StateType `json:"state,omitempty"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Assignees []*User `json:"assignees,omitempty"` + Attachments []*Attachment `json:"assets,omitempty"` + Body string `json:"body,omitempty"` + Closed time.Time `json:"closed_at,omitempty"` + Comments int64 `json:"comments,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + Index int64 `json:"number,omitempty"` + IsLocked bool `json:"is_locked,omitempty"` + Labels []*Label `json:"labels,omitempty"` + Milestone *Milestone `json:"milestone,omitempty"` + OriginalAuthor string `json:"original_author,omitempty"` + OriginalAuthorID int64 `json:"original_author_id,omitempty"` + PinOrder int64 `json:"pin_order,omitempty"` + PullRequest *PullRequestMeta `json:"pull_request,omitempty"` + Ref string `json:"ref,omitempty"` + Repository *RepositoryMeta `json:"repository,omitempty"` + State StateType `json:"state,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } +// Usage: +// +// opts := IssueConfig{BlankIssuesEnabled: true} type IssueConfig struct { - BlankIssuesEnabled bool `json:"blank_issues_enabled,omitempty"` - ContactLinks []*IssueConfigContactLink `json:"contact_links,omitempty"` + BlankIssuesEnabled bool `json:"blank_issues_enabled,omitempty"` + ContactLinks []*IssueConfigContactLink `json:"contact_links,omitempty"` } +// Usage: +// +// opts := IssueConfigContactLink{Name: "example"} type IssueConfigContactLink struct { About string `json:"about,omitempty"` - Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` } +// Usage: +// +// opts := IssueConfigValidation{Message: "example"} type IssueConfigValidation struct { Message string `json:"message,omitempty"` - Valid bool `json:"valid,omitempty"` + Valid bool `json:"valid,omitempty"` } // IssueDeadline — IssueDeadline represents an issue deadline +// +// Usage: +// +// opts := IssueDeadline{Deadline: time.Now()} type IssueDeadline struct { Deadline time.Time `json:"due_date,omitempty"` } // IssueFormField — IssueFormField represents a form field +// +// Usage: +// +// opts := IssueFormField{ID: "example"} type IssueFormField struct { - Attributes map[string]any `json:"attributes,omitempty"` - ID string `json:"id,omitempty"` - Type *IssueFormFieldType `json:"type,omitempty"` - Validations map[string]any `json:"validations,omitempty"` - Visible []*IssueFormFieldVisible `json:"visible,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` + ID string `json:"id,omitempty"` + Type IssueFormFieldType `json:"type,omitempty"` + Validations map[string]any `json:"validations,omitempty"` + Visible []IssueFormFieldVisible `json:"visible,omitempty"` } -// IssueFormFieldType has no fields in the swagger spec. -type IssueFormFieldType struct{} +// Usage: +// +// opts := IssueFormFieldType("example") +type IssueFormFieldType string // IssueFormFieldVisible — IssueFormFieldVisible defines issue form field visible -// IssueFormFieldVisible has no fields in the swagger spec. -type IssueFormFieldVisible struct{} +// +// Usage: +// +// opts := IssueFormFieldVisible("example") +type IssueFormFieldVisible string // IssueLabelsOption — IssueLabelsOption a collection of labels +// +// Usage: +// +// opts := IssueLabelsOption{Updated: time.Now()} type IssueLabelsOption struct { - Labels []any `json:"labels,omitempty"` // Labels can be a list of integers representing label IDs or a list of strings representing label names + Labels []any `json:"labels,omitempty"` // Labels can be a list of integers representing label IDs or a list of strings representing label names Updated time.Time `json:"updated_at,omitempty"` } // IssueMeta — IssueMeta basic issue information +// +// Usage: +// +// opts := IssueMeta{Name: "example"} type IssueMeta struct { - Index int64 `json:"index,omitempty"` - Name string `json:"repo,omitempty"` + Index int64 `json:"index,omitempty"` + Name string `json:"repo,omitempty"` Owner string `json:"owner,omitempty"` } // IssueTemplate — IssueTemplate represents an issue template for a repository +// +// Usage: +// +// opts := IssueTemplate{FileName: "example"} type IssueTemplate struct { - About string `json:"about,omitempty"` - Content string `json:"content,omitempty"` - Fields []*IssueFormField `json:"body,omitempty"` - FileName string `json:"file_name,omitempty"` - Labels *IssueTemplateLabels `json:"labels,omitempty"` - Name string `json:"name,omitempty"` - Ref string `json:"ref,omitempty"` - Title string `json:"title,omitempty"` + About string `json:"about,omitempty"` + Content string `json:"content,omitempty"` + Fields []*IssueFormField `json:"body,omitempty"` + FileName string `json:"file_name,omitempty"` + Labels IssueTemplateLabels `json:"labels,omitempty"` + Name string `json:"name,omitempty"` + Ref string `json:"ref,omitempty"` + Title string `json:"title,omitempty"` } -// IssueTemplateLabels has no fields in the swagger spec. -type IssueTemplateLabels struct{} - +// Usage: +// +// opts := IssueTemplateLabels([]string{"example"}) +type IssueTemplateLabels []string diff --git a/types/key.go b/types/key.go index 4415146..c5ec942 100644 --- a/types/key.go +++ b/types/key.go @@ -4,66 +4,88 @@ package types import "time" - // CreateGPGKeyOption — CreateGPGKeyOption options create user GPG key +// +// Usage: +// +// opts := CreateGPGKeyOption{ArmoredKey: "example"} type CreateGPGKeyOption struct { ArmoredKey string `json:"armored_public_key"` // An armored GPG key to add - Signature string `json:"armored_signature,omitempty"` + Signature string `json:"armored_signature,omitempty"` } // CreateKeyOption — CreateKeyOption options when creating a key +// +// Usage: +// +// opts := CreateKeyOption{Title: "example"} type CreateKeyOption struct { - Key string `json:"key"` // An armored SSH key to add - ReadOnly bool `json:"read_only,omitempty"` // Describe if the key has only read access or read/write - Title string `json:"title"` // Title of the key to add + Key string `json:"key"` // An armored SSH key to add + ReadOnly bool `json:"read_only,omitempty"` // Describe if the key has only read access or read/write + Title string `json:"title"` // Title of the key to add } // DeployKey — DeployKey a deploy key +// +// Usage: +// +// opts := DeployKey{Title: "example"} type DeployKey struct { - Created time.Time `json:"created_at,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - ID int64 `json:"id,omitempty"` - Key string `json:"key,omitempty"` - KeyID int64 `json:"key_id,omitempty"` - ReadOnly bool `json:"read_only,omitempty"` - Repository *Repository `json:"repository,omitempty"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + ID int64 `json:"id,omitempty"` + Key string `json:"key,omitempty"` + KeyID int64 `json:"key_id,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + Repository *Repository `json:"repository,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` } // GPGKey — GPGKey a user GPG key to sign commit and tag in repository +// +// Usage: +// +// opts := GPGKey{KeyID: "example"} type GPGKey struct { - CanCertify bool `json:"can_certify,omitempty"` - CanEncryptComms bool `json:"can_encrypt_comms,omitempty"` - CanEncryptStorage bool `json:"can_encrypt_storage,omitempty"` - CanSign bool `json:"can_sign,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Emails []*GPGKeyEmail `json:"emails,omitempty"` - Expires time.Time `json:"expires_at,omitempty"` - ID int64 `json:"id,omitempty"` - KeyID string `json:"key_id,omitempty"` - PrimaryKeyID string `json:"primary_key_id,omitempty"` - PublicKey string `json:"public_key,omitempty"` - SubsKey []*GPGKey `json:"subkeys,omitempty"` - Verified bool `json:"verified,omitempty"` + CanCertify bool `json:"can_certify,omitempty"` + CanEncryptComms bool `json:"can_encrypt_comms,omitempty"` + CanEncryptStorage bool `json:"can_encrypt_storage,omitempty"` + CanSign bool `json:"can_sign,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Emails []*GPGKeyEmail `json:"emails,omitempty"` + Expires time.Time `json:"expires_at,omitempty"` + ID int64 `json:"id,omitempty"` + KeyID string `json:"key_id,omitempty"` + PrimaryKeyID string `json:"primary_key_id,omitempty"` + PublicKey string `json:"public_key,omitempty"` + SubsKey []*GPGKey `json:"subkeys,omitempty"` + Verified bool `json:"verified,omitempty"` } // GPGKeyEmail — GPGKeyEmail an email attached to a GPGKey +// +// Usage: +// +// opts := GPGKeyEmail{Email: "alice@example.com"} type GPGKeyEmail struct { - Email string `json:"email,omitempty"` - Verified bool `json:"verified,omitempty"` + Email string `json:"email,omitempty"` + Verified bool `json:"verified,omitempty"` } // PublicKey — PublicKey publickey is a user key to push code to repository +// +// Usage: +// +// opts := PublicKey{Title: "example"} type PublicKey struct { - Created time.Time `json:"created_at,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - ID int64 `json:"id,omitempty"` - Key string `json:"key,omitempty"` - KeyType string `json:"key_type,omitempty"` - ReadOnly bool `json:"read_only,omitempty"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - User *User `json:"user,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + ID int64 `json:"id,omitempty"` + Key string `json:"key,omitempty"` + KeyType string `json:"key_type,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + User *User `json:"user,omitempty"` } - diff --git a/types/label.go b/types/label.go index bdb79f1..d44d692 100644 --- a/types/label.go +++ b/types/label.go @@ -4,46 +4,64 @@ package types import "time" - // CreateLabelOption — CreateLabelOption options for creating a label +// +// Usage: +// +// opts := CreateLabelOption{Name: "example"} type CreateLabelOption struct { - Color string `json:"color"` + Color string `json:"color"` Description string `json:"description,omitempty"` - Exclusive bool `json:"exclusive,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` - Name string `json:"name"` + Exclusive bool `json:"exclusive,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + Name string `json:"name"` } // DeleteLabelsOption — DeleteLabelOption options for deleting a label +// +// Usage: +// +// opts := DeleteLabelsOption{Updated: time.Now()} type DeleteLabelsOption struct { Updated time.Time `json:"updated_at,omitempty"` } // EditLabelOption — EditLabelOption options for editing a label +// +// Usage: +// +// opts := EditLabelOption{Description: "example"} type EditLabelOption struct { - Color string `json:"color,omitempty"` + Color string `json:"color,omitempty"` Description string `json:"description,omitempty"` - Exclusive bool `json:"exclusive,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` - Name string `json:"name,omitempty"` + Exclusive bool `json:"exclusive,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + Name string `json:"name,omitempty"` } // Label — Label a label to an issue or a pr +// +// Usage: +// +// opts := Label{Description: "example"} type Label struct { - Color string `json:"color,omitempty"` + Color string `json:"color,omitempty"` Description string `json:"description,omitempty"` - Exclusive bool `json:"exclusive,omitempty"` - ID int64 `json:"id,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` - Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + Exclusive bool `json:"exclusive,omitempty"` + ID int64 `json:"id,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` } // LabelTemplate — LabelTemplate info of a Label template +// +// Usage: +// +// opts := LabelTemplate{Description: "example"} type LabelTemplate struct { - Color string `json:"color,omitempty"` + Color string `json:"color,omitempty"` Description string `json:"description,omitempty"` - Exclusive bool `json:"exclusive,omitempty"` - Name string `json:"name,omitempty"` + Exclusive bool `json:"exclusive,omitempty"` + Name string `json:"name,omitempty"` } - diff --git a/types/milestone.go b/types/milestone.go index 6d294d5..ba1f0b4 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -4,34 +4,44 @@ package types import "time" - // CreateMilestoneOption — CreateMilestoneOption options for creating a milestone +// +// Usage: +// +// opts := CreateMilestoneOption{Description: "example"} type CreateMilestoneOption struct { - Deadline time.Time `json:"due_on,omitempty"` - Description string `json:"description,omitempty"` - State string `json:"state,omitempty"` - Title string `json:"title,omitempty"` + Deadline time.Time `json:"due_on,omitempty"` + Description string `json:"description,omitempty"` + State string `json:"state,omitempty"` + Title string `json:"title,omitempty"` } // EditMilestoneOption — EditMilestoneOption options for editing a milestone +// +// Usage: +// +// opts := EditMilestoneOption{Description: "example"} type EditMilestoneOption struct { - Deadline time.Time `json:"due_on,omitempty"` - Description string `json:"description,omitempty"` - State string `json:"state,omitempty"` - Title string `json:"title,omitempty"` + Deadline time.Time `json:"due_on,omitempty"` + Description string `json:"description,omitempty"` + State string `json:"state,omitempty"` + Title string `json:"title,omitempty"` } // Milestone — Milestone milestone is a collection of issues on one repository +// +// Usage: +// +// opts := Milestone{Description: "example"} type Milestone struct { - Closed time.Time `json:"closed_at,omitempty"` - ClosedIssues int64 `json:"closed_issues,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Deadline time.Time `json:"due_on,omitempty"` - Description string `json:"description,omitempty"` - ID int64 `json:"id,omitempty"` - OpenIssues int64 `json:"open_issues,omitempty"` - State *StateType `json:"state,omitempty"` - Title string `json:"title,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` + Closed time.Time `json:"closed_at,omitempty"` + ClosedIssues int64 `json:"closed_issues,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Deadline time.Time `json:"due_on,omitempty"` + Description string `json:"description,omitempty"` + ID int64 `json:"id,omitempty"` + OpenIssues int64 `json:"open_issues,omitempty"` + State StateType `json:"state,omitempty"` + Title string `json:"title,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` } - diff --git a/types/misc.go b/types/misc.go index 271a8fc..3da46b9 100644 --- a/types/misc.go +++ b/types/misc.go @@ -4,252 +4,360 @@ package types import "time" - // AddCollaboratorOption — AddCollaboratorOption options when adding a user as a collaborator of a repository +// +// Usage: +// +// opts := AddCollaboratorOption{Permission: "example"} type AddCollaboratorOption struct { Permission string `json:"permission,omitempty"` } // AddTimeOption — AddTimeOption options for adding time to an issue +// +// Usage: +// +// opts := AddTimeOption{Time: 1} type AddTimeOption struct { Created time.Time `json:"created,omitempty"` - Time int64 `json:"time"` // time in seconds - User string `json:"user_name,omitempty"` // User who spent the time (optional) + Time int64 `json:"time"` // time in seconds + User string `json:"user_name,omitempty"` // User who spent the time (optional) } // ChangeFileOperation — ChangeFileOperation for creating, updating or deleting a file +// +// Usage: +// +// opts := ChangeFileOperation{Operation: "example"} type ChangeFileOperation struct { - ContentBase64 string `json:"content,omitempty"` // new or updated file content, must be base64 encoded - FromPath string `json:"from_path,omitempty"` // old path of the file to move - Operation string `json:"operation"` // indicates what to do with the file - Path string `json:"path"` // path to the existing or new file - SHA string `json:"sha,omitempty"` // sha is the SHA for the file that already exists, required for update or delete + ContentBase64 string `json:"content,omitempty"` // new or updated file content, must be base64 encoded + FromPath string `json:"from_path,omitempty"` // old path of the file to move + Operation string `json:"operation"` // indicates what to do with the file + Path string `json:"path"` // path to the existing or new file + SHA string `json:"sha,omitempty"` // sha is the SHA for the file that already exists, required for update or delete } // ChangeFilesOptions — ChangeFilesOptions options for creating, updating or deleting multiple files Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +// +// Usage: +// +// opts := ChangeFilesOptions{Files: {}} type ChangeFilesOptions struct { - Author *Identity `json:"author,omitempty"` - BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used - Committer *Identity `json:"committer,omitempty"` - Dates *CommitDateOptions `json:"dates,omitempty"` - Files []*ChangeFileOperation `json:"files"` // list of file operations - Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used - NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file - Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. + Author *Identity `json:"author,omitempty"` + BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used + Committer *Identity `json:"committer,omitempty"` + Dates *CommitDateOptions `json:"dates,omitempty"` + Files []*ChangeFileOperation `json:"files"` // list of file operations + Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used + NewBranchName string `json:"new_branch,omitempty"` // new_branch (optional) will make a new branch from `branch` before creating the file + Signoff bool `json:"signoff,omitempty"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. } +// Usage: +// +// opts := Compare{TotalCommits: 1} type Compare struct { - Commits []*Commit `json:"commits,omitempty"` - TotalCommits int64 `json:"total_commits,omitempty"` + Commits []*Commit `json:"commits,omitempty"` + TotalCommits int64 `json:"total_commits,omitempty"` } // CreateForkOption — CreateForkOption options for creating a fork +// +// Usage: +// +// opts := CreateForkOption{Name: "example"} type CreateForkOption struct { - Name string `json:"name,omitempty"` // name of the forked repository + Name string `json:"name,omitempty"` // name of the forked repository Organization string `json:"organization,omitempty"` // organization name, if forking into an organization } // CreateOrUpdateSecretOption — CreateOrUpdateSecretOption options when creating or updating secret +// +// Usage: +// +// opts := CreateOrUpdateSecretOption{Data: "example"} type CreateOrUpdateSecretOption struct { Data string `json:"data"` // Data of the secret to update } // DismissPullReviewOptions — DismissPullReviewOptions are options to dismiss a pull review +// +// Usage: +// +// opts := DismissPullReviewOptions{Message: "example"} type DismissPullReviewOptions struct { Message string `json:"message,omitempty"` - Priors bool `json:"priors,omitempty"` + Priors bool `json:"priors,omitempty"` } // ForgeLike — ForgeLike activity data type +// +// Usage: +// +// opts := ForgeLike{} +// // ForgeLike has no fields in the swagger spec. type ForgeLike struct{} // GenerateRepoOption — GenerateRepoOption options when creating repository using a template +// +// Usage: +// +// opts := GenerateRepoOption{Name: "example"} type GenerateRepoOption struct { - Avatar bool `json:"avatar,omitempty"` // include avatar of the template repo - DefaultBranch string `json:"default_branch,omitempty"` // Default branch of the new repository - Description string `json:"description,omitempty"` // Description of the repository to create - GitContent bool `json:"git_content,omitempty"` // include git content of default branch in template repo - GitHooks bool `json:"git_hooks,omitempty"` // include git hooks in template repo - Labels bool `json:"labels,omitempty"` // include labels in template repo - Name string `json:"name"` // Name of the repository to create - Owner string `json:"owner"` // The organization or person who will own the new repository - Private bool `json:"private,omitempty"` // Whether the repository is private - ProtectedBranch bool `json:"protected_branch,omitempty"` // include protected branches in template repo - Topics bool `json:"topics,omitempty"` // include topics in template repo - Webhooks bool `json:"webhooks,omitempty"` // include webhooks in template repo + Avatar bool `json:"avatar,omitempty"` // include avatar of the template repo + DefaultBranch string `json:"default_branch,omitempty"` // Default branch of the new repository + Description string `json:"description,omitempty"` // Description of the repository to create + GitContent bool `json:"git_content,omitempty"` // include git content of default branch in template repo + GitHooks bool `json:"git_hooks,omitempty"` // include git hooks in template repo + Labels bool `json:"labels,omitempty"` // include labels in template repo + Name string `json:"name"` // Name of the repository to create + Owner string `json:"owner"` // The organization or person who will own the new repository + Private bool `json:"private,omitempty"` // Whether the repository is private + ProtectedBranch bool `json:"protected_branch,omitempty"` // include protected branches in template repo + Topics bool `json:"topics,omitempty"` // include topics in template repo + Webhooks bool `json:"webhooks,omitempty"` // include webhooks in template repo } // GitignoreTemplateInfo — GitignoreTemplateInfo name and text of a gitignore template +// +// Usage: +// +// opts := GitignoreTemplateInfo{Name: "example"} type GitignoreTemplateInfo struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` Source string `json:"source,omitempty"` } // Identity — Identity for a person's identity like an author or committer +// +// Usage: +// +// opts := Identity{Name: "example"} type Identity struct { Email string `json:"email,omitempty"` - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` } // LicenseTemplateInfo — LicensesInfo contains information about a License +// +// Usage: +// +// opts := LicenseTemplateInfo{Body: "example"} type LicenseTemplateInfo struct { - Body string `json:"body,omitempty"` + Body string `json:"body,omitempty"` Implementation string `json:"implementation,omitempty"` - Key string `json:"key,omitempty"` - Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` } // LicensesTemplateListEntry — LicensesListEntry is used for the API +// +// Usage: +// +// opts := LicensesTemplateListEntry{Name: "example"} type LicensesTemplateListEntry struct { - Key string `json:"key,omitempty"` + Key string `json:"key,omitempty"` Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } // MarkdownOption — MarkdownOption markdown options +// +// Usage: +// +// opts := MarkdownOption{Context: "example"} type MarkdownOption struct { Context string `json:"Context,omitempty"` // Context to render in: body - Mode string `json:"Mode,omitempty"` // Mode to render (comment, gfm, markdown) in: body - Text string `json:"Text,omitempty"` // Text markdown to render in: body - Wiki bool `json:"Wiki,omitempty"` // Is it a wiki page ? in: body + Mode string `json:"Mode,omitempty"` // Mode to render (comment, gfm, markdown) in: body + Text string `json:"Text,omitempty"` // Text markdown to render in: body + Wiki bool `json:"Wiki,omitempty"` // Is it a wiki page ? in: body } // MarkupOption — MarkupOption markup options +// +// Usage: +// +// opts := MarkupOption{BranchPath: "main"} type MarkupOption struct { BranchPath string `json:"BranchPath,omitempty"` // The current branch path where the form gets posted in: body - Context string `json:"Context,omitempty"` // Context to render in: body - FilePath string `json:"FilePath,omitempty"` // File path for detecting extension in file mode in: body - Mode string `json:"Mode,omitempty"` // Mode to render (comment, gfm, markdown, file) in: body - Text string `json:"Text,omitempty"` // Text markup to render in: body - Wiki bool `json:"Wiki,omitempty"` // Is it a wiki page ? in: body + Context string `json:"Context,omitempty"` // Context to render in: body + FilePath string `json:"FilePath,omitempty"` // File path for detecting extension in file mode in: body + Mode string `json:"Mode,omitempty"` // Mode to render (comment, gfm, markdown, file) in: body + Text string `json:"Text,omitempty"` // Text markup to render in: body + Wiki bool `json:"Wiki,omitempty"` // Is it a wiki page ? in: body } // MergePullRequestOption — MergePullRequestForm form for merging Pull Request +// +// Usage: +// +// opts := MergePullRequestOption{Do: "example"} type MergePullRequestOption struct { - DeleteBranchAfterMerge bool `json:"delete_branch_after_merge,omitempty"` - Do string `json:"Do"` - ForceMerge bool `json:"force_merge,omitempty"` - HeadCommitID string `json:"head_commit_id,omitempty"` - MergeCommitID string `json:"MergeCommitID,omitempty"` - MergeMessageField string `json:"MergeMessageField,omitempty"` - MergeTitleField string `json:"MergeTitleField,omitempty"` - MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"` + DeleteBranchAfterMerge bool `json:"delete_branch_after_merge,omitempty"` + Do string `json:"Do"` + ForceMerge bool `json:"force_merge,omitempty"` + HeadCommitID string `json:"head_commit_id,omitempty"` + MergeCommitID string `json:"MergeCommitID,omitempty"` + MergeMessageField string `json:"MergeMessageField,omitempty"` + MergeTitleField string `json:"MergeTitleField,omitempty"` + MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"` } // MigrateRepoOptions — MigrateRepoOptions options for migrating repository's this is used to interact with api v1 +// +// Usage: +// +// opts := MigrateRepoOptions{RepoName: "example"} type MigrateRepoOptions struct { - AuthPassword string `json:"auth_password,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - AuthUsername string `json:"auth_username,omitempty"` - CloneAddr string `json:"clone_addr"` - Description string `json:"description,omitempty"` - Issues bool `json:"issues,omitempty"` - LFS bool `json:"lfs,omitempty"` - LFSEndpoint string `json:"lfs_endpoint,omitempty"` - Labels bool `json:"labels,omitempty"` - Milestones bool `json:"milestones,omitempty"` - Mirror bool `json:"mirror,omitempty"` + AuthPassword string `json:"auth_password,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + AuthUsername string `json:"auth_username,omitempty"` + CloneAddr string `json:"clone_addr"` + Description string `json:"description,omitempty"` + Issues bool `json:"issues,omitempty"` + LFS bool `json:"lfs,omitempty"` + LFSEndpoint string `json:"lfs_endpoint,omitempty"` + Labels bool `json:"labels,omitempty"` + Milestones bool `json:"milestones,omitempty"` + Mirror bool `json:"mirror,omitempty"` MirrorInterval string `json:"mirror_interval,omitempty"` - Private bool `json:"private,omitempty"` - PullRequests bool `json:"pull_requests,omitempty"` - Releases bool `json:"releases,omitempty"` - RepoName string `json:"repo_name"` - RepoOwner string `json:"repo_owner,omitempty"` // Name of User or Organisation who will own Repo after migration - RepoOwnerID int64 `json:"uid,omitempty"` // deprecated (only for backwards compatibility) - Service string `json:"service,omitempty"` - Wiki bool `json:"wiki,omitempty"` + Private bool `json:"private,omitempty"` + PullRequests bool `json:"pull_requests,omitempty"` + Releases bool `json:"releases,omitempty"` + RepoName string `json:"repo_name"` + RepoOwner string `json:"repo_owner,omitempty"` // Name of User or Organisation who will own Repo after migration + RepoOwnerID int64 `json:"uid,omitempty"` // deprecated (only for backwards compatibility) + Service string `json:"service,omitempty"` + Wiki bool `json:"wiki,omitempty"` } // NewIssuePinsAllowed — NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed +// +// Usage: +// +// opts := NewIssuePinsAllowed{Issues: true} type NewIssuePinsAllowed struct { - Issues bool `json:"issues,omitempty"` + Issues bool `json:"issues,omitempty"` PullRequests bool `json:"pull_requests,omitempty"` } // NotifySubjectType — NotifySubjectType represent type of notification subject -// NotifySubjectType has no fields in the swagger spec. -type NotifySubjectType struct{} +// +// Usage: +// +// opts := NotifySubjectType("example") +type NotifySubjectType string // PRBranchInfo — PRBranchInfo information about a branch +// +// Usage: +// +// opts := PRBranchInfo{Name: "example"} type PRBranchInfo struct { - Name string `json:"label,omitempty"` - Ref string `json:"ref,omitempty"` - Repo *Repository `json:"repo,omitempty"` - RepoID int64 `json:"repo_id,omitempty"` - Sha string `json:"sha,omitempty"` + Name string `json:"label,omitempty"` + Ref string `json:"ref,omitempty"` + Repo *Repository `json:"repo,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + Sha string `json:"sha,omitempty"` } // PayloadUser — PayloadUser represents the author or committer of a commit +// +// Usage: +// +// opts := PayloadUser{Name: "example"} type PayloadUser struct { - Email string `json:"email,omitempty"` - Name string `json:"name,omitempty"` // Full name of the commit author + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` // Full name of the commit author UserName string `json:"username,omitempty"` } +// Usage: +// +// opts := Reference{Ref: "main"} type Reference struct { Object *GitObject `json:"object,omitempty"` - Ref string `json:"ref,omitempty"` - URL string `json:"url,omitempty"` + Ref string `json:"ref,omitempty"` + URL string `json:"url,omitempty"` } // ReplaceFlagsOption — ReplaceFlagsOption options when replacing the flags of a repository +// +// Usage: +// +// opts := ReplaceFlagsOption{Flags: []string{"example"}} type ReplaceFlagsOption struct { Flags []string `json:"flags,omitempty"` } // SearchResults — SearchResults results of a successful search +// +// Usage: +// +// opts := SearchResults{OK: true} type SearchResults struct { Data []*Repository `json:"data,omitempty"` - OK bool `json:"ok,omitempty"` + OK bool `json:"ok,omitempty"` } // ServerVersion — ServerVersion wraps the version of the server +// +// Usage: +// +// opts := ServerVersion{Version: "example"} type ServerVersion struct { Version string `json:"version,omitempty"` } // TimelineComment — TimelineComment represents a timeline comment (comment of any type) on a commit or issue +// +// Usage: +// +// opts := TimelineComment{Body: "example"} type TimelineComment struct { - Assignee *User `json:"assignee,omitempty"` - AssigneeTeam *Team `json:"assignee_team,omitempty"` - Body string `json:"body,omitempty"` - Created time.Time `json:"created_at,omitempty"` - DependentIssue *Issue `json:"dependent_issue,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - IssueURL string `json:"issue_url,omitempty"` - Label *Label `json:"label,omitempty"` - Milestone *Milestone `json:"milestone,omitempty"` - NewRef string `json:"new_ref,omitempty"` - NewTitle string `json:"new_title,omitempty"` - OldMilestone *Milestone `json:"old_milestone,omitempty"` - OldProjectID int64 `json:"old_project_id,omitempty"` - OldRef string `json:"old_ref,omitempty"` - OldTitle string `json:"old_title,omitempty"` - PRURL string `json:"pull_request_url,omitempty"` - ProjectID int64 `json:"project_id,omitempty"` - RefAction string `json:"ref_action,omitempty"` - RefComment *Comment `json:"ref_comment,omitempty"` - RefCommitSHA string `json:"ref_commit_sha,omitempty"` // commit SHA where issue/PR was referenced - RefIssue *Issue `json:"ref_issue,omitempty"` - RemovedAssignee bool `json:"removed_assignee,omitempty"` // whether the assignees were removed or added - ResolveDoer *User `json:"resolve_doer,omitempty"` - ReviewID int64 `json:"review_id,omitempty"` - TrackedTime *TrackedTime `json:"tracked_time,omitempty"` - Type string `json:"type,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Assignee *User `json:"assignee,omitempty"` + AssigneeTeam *Team `json:"assignee_team,omitempty"` + Body string `json:"body,omitempty"` + Created time.Time `json:"created_at,omitempty"` + DependentIssue *Issue `json:"dependent_issue,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + IssueURL string `json:"issue_url,omitempty"` + Label *Label `json:"label,omitempty"` + Milestone *Milestone `json:"milestone,omitempty"` + NewRef string `json:"new_ref,omitempty"` + NewTitle string `json:"new_title,omitempty"` + OldMilestone *Milestone `json:"old_milestone,omitempty"` + OldProjectID int64 `json:"old_project_id,omitempty"` + OldRef string `json:"old_ref,omitempty"` + OldTitle string `json:"old_title,omitempty"` + PRURL string `json:"pull_request_url,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` + RefAction string `json:"ref_action,omitempty"` + RefComment *Comment `json:"ref_comment,omitempty"` + RefCommitSHA string `json:"ref_commit_sha,omitempty"` // commit SHA where issue/PR was referenced + RefIssue *Issue `json:"ref_issue,omitempty"` + RemovedAssignee bool `json:"removed_assignee,omitempty"` // whether the assignees were removed or added + ResolveDoer *User `json:"resolve_doer,omitempty"` + ReviewID int64 `json:"review_id,omitempty"` + TrackedTime *TrackedTime `json:"tracked_time,omitempty"` + Type string `json:"type,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } // WatchInfo — WatchInfo represents an API watch status of one repository +// +// Usage: +// +// opts := WatchInfo{RepositoryURL: "https://example.com"} type WatchInfo struct { - CreatedAt time.Time `json:"created_at,omitempty"` - Ignored bool `json:"ignored,omitempty"` - Reason any `json:"reason,omitempty"` - RepositoryURL string `json:"repository_url,omitempty"` - Subscribed bool `json:"subscribed,omitempty"` - URL string `json:"url,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Ignored bool `json:"ignored,omitempty"` + Reason any `json:"reason,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Subscribed bool `json:"subscribed,omitempty"` + URL string `json:"url,omitempty"` } - diff --git a/types/notification.go b/types/notification.go index 3d84aa7..3cbae6c 100644 --- a/types/notification.go +++ b/types/notification.go @@ -4,31 +4,41 @@ package types import "time" - // NotificationCount — NotificationCount number of unread notifications +// +// Usage: +// +// opts := NotificationCount{New: 1} type NotificationCount struct { New int64 `json:"new,omitempty"` } // NotificationSubject — NotificationSubject contains the notification subject (Issue/Pull/Commit) +// +// Usage: +// +// opts := NotificationSubject{Title: "example"} type NotificationSubject struct { - HTMLURL string `json:"html_url,omitempty"` - LatestCommentHTMLURL string `json:"latest_comment_html_url,omitempty"` - LatestCommentURL string `json:"latest_comment_url,omitempty"` - State *StateType `json:"state,omitempty"` - Title string `json:"title,omitempty"` - Type *NotifySubjectType `json:"type,omitempty"` - URL string `json:"url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + LatestCommentHTMLURL string `json:"latest_comment_html_url,omitempty"` + LatestCommentURL string `json:"latest_comment_url,omitempty"` + State StateType `json:"state,omitempty"` + Title string `json:"title,omitempty"` + Type NotifySubjectType `json:"type,omitempty"` + URL string `json:"url,omitempty"` } // NotificationThread — NotificationThread expose Notification on API +// +// Usage: +// +// opts := NotificationThread{URL: "https://example.com"} type NotificationThread struct { - ID int64 `json:"id,omitempty"` - Pinned bool `json:"pinned,omitempty"` - Repository *Repository `json:"repository,omitempty"` - Subject *NotificationSubject `json:"subject,omitempty"` - URL string `json:"url,omitempty"` - Unread bool `json:"unread,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + Pinned bool `json:"pinned,omitempty"` + Repository *Repository `json:"repository,omitempty"` + Subject *NotificationSubject `json:"subject,omitempty"` + URL string `json:"url,omitempty"` + Unread bool `json:"unread,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } - diff --git a/types/oauth.go b/types/oauth.go index 28501d7..cc41c61 100644 --- a/types/oauth.go +++ b/types/oauth.go @@ -4,35 +4,47 @@ package types import "time" - +// Usage: +// +// opts := AccessToken{Name: "example"} type AccessToken struct { - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Scopes []string `json:"scopes,omitempty"` - Token string `json:"sha1,omitempty"` - TokenLastEight string `json:"token_last_eight,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Token string `json:"sha1,omitempty"` + TokenLastEight string `json:"token_last_eight,omitempty"` } // CreateAccessTokenOption — CreateAccessTokenOption options when create access token +// +// Usage: +// +// opts := CreateAccessTokenOption{Name: "example"} type CreateAccessTokenOption struct { - Name string `json:"name"` + Name string `json:"name"` Scopes []string `json:"scopes,omitempty"` } // CreateOAuth2ApplicationOptions — CreateOAuth2ApplicationOptions holds options to create an oauth2 application +// +// Usage: +// +// opts := CreateOAuth2ApplicationOptions{Name: "example"} type CreateOAuth2ApplicationOptions struct { - ConfidentialClient bool `json:"confidential_client,omitempty"` - Name string `json:"name,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` + ConfidentialClient bool `json:"confidential_client,omitempty"` + Name string `json:"name,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` } +// Usage: +// +// opts := OAuth2Application{Name: "example"} type OAuth2Application struct { - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - ConfidentialClient bool `json:"confidential_client,omitempty"` - Created time.Time `json:"created,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + ConfidentialClient bool `json:"confidential_client,omitempty"` + Created time.Time `json:"created,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` } - diff --git a/types/org.go b/types/org.go index fdc63a9..24c62ce 100644 --- a/types/org.go +++ b/types/org.go @@ -2,51 +2,65 @@ package types - // CreateOrgOption — CreateOrgOption options for creating an organization +// +// Usage: +// +// opts := CreateOrgOption{UserName: "example"} type CreateOrgOption struct { - Description string `json:"description,omitempty"` - Email string `json:"email,omitempty"` - FullName string `json:"full_name,omitempty"` - Location string `json:"location,omitempty"` - RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` - UserName string `json:"username"` - Visibility string `json:"visibility,omitempty"` // possible values are `public` (default), `limited` or `private` - Website string `json:"website,omitempty"` + Description string `json:"description,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` + Location string `json:"location,omitempty"` + RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` + UserName string `json:"username"` + Visibility string `json:"visibility,omitempty"` // possible values are `public` (default), `limited` or `private` + Website string `json:"website,omitempty"` } // EditOrgOption — EditOrgOption options for editing an organization +// +// Usage: +// +// opts := EditOrgOption{Description: "example"} type EditOrgOption struct { - Description string `json:"description,omitempty"` - Email string `json:"email,omitempty"` - FullName string `json:"full_name,omitempty"` - Location string `json:"location,omitempty"` - RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` - Visibility string `json:"visibility,omitempty"` // possible values are `public`, `limited` or `private` - Website string `json:"website,omitempty"` + Description string `json:"description,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` + Location string `json:"location,omitempty"` + RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` + Visibility string `json:"visibility,omitempty"` // possible values are `public`, `limited` or `private` + Website string `json:"website,omitempty"` } // Organization — Organization represents an organization +// +// Usage: +// +// opts := Organization{Description: "example"} type Organization struct { - AvatarURL string `json:"avatar_url,omitempty"` - Description string `json:"description,omitempty"` - Email string `json:"email,omitempty"` - FullName string `json:"full_name,omitempty"` - ID int64 `json:"id,omitempty"` - Location string `json:"location,omitempty"` - Name string `json:"name,omitempty"` - RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` - UserName string `json:"username,omitempty"` // deprecated - Visibility string `json:"visibility,omitempty"` - Website string `json:"website,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Description string `json:"description,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` + ID int64 `json:"id,omitempty"` + Location string `json:"location,omitempty"` + Name string `json:"name,omitempty"` + RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access,omitempty"` + UserName string `json:"username,omitempty"` // deprecated + Visibility string `json:"visibility,omitempty"` + Website string `json:"website,omitempty"` } // OrganizationPermissions — OrganizationPermissions list different users permissions on an organization +// +// Usage: +// +// opts := OrganizationPermissions{CanCreateRepository: true} type OrganizationPermissions struct { CanCreateRepository bool `json:"can_create_repository,omitempty"` - CanRead bool `json:"can_read,omitempty"` - CanWrite bool `json:"can_write,omitempty"` - IsAdmin bool `json:"is_admin,omitempty"` - IsOwner bool `json:"is_owner,omitempty"` + CanRead bool `json:"can_read,omitempty"` + CanWrite bool `json:"can_write,omitempty"` + IsAdmin bool `json:"is_admin,omitempty"` + IsOwner bool `json:"is_owner,omitempty"` } - diff --git a/types/package.go b/types/package.go index 49f9e14..2576ca4 100644 --- a/types/package.go +++ b/types/package.go @@ -4,28 +4,34 @@ package types import "time" - // Package — Package represents a package +// +// Usage: +// +// opts := Package{Name: "example"} type Package struct { - CreatedAt time.Time `json:"created_at,omitempty"` - Creator *User `json:"creator,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Owner *User `json:"owner,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Creator *User `json:"creator,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Owner *User `json:"owner,omitempty"` Repository *Repository `json:"repository,omitempty"` - Type string `json:"type,omitempty"` - Version string `json:"version,omitempty"` + Type string `json:"type,omitempty"` + Version string `json:"version,omitempty"` } // PackageFile — PackageFile represents a package file +// +// Usage: +// +// opts := PackageFile{Name: "example"} type PackageFile struct { - HashMD5 string `json:"md5,omitempty"` - HashSHA1 string `json:"sha1,omitempty"` + HashMD5 string `json:"md5,omitempty"` + HashSHA1 string `json:"sha1,omitempty"` HashSHA256 string `json:"sha256,omitempty"` HashSHA512 string `json:"sha512,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Size int64 `json:"Size,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Size int64 `json:"Size,omitempty"` } - diff --git a/types/pr.go b/types/pr.go index 6aa28ed..7204103 100644 --- a/types/pr.go +++ b/types/pr.go @@ -4,150 +4,191 @@ package types import "time" - // CreatePullRequestOption — CreatePullRequestOption options when creating a pull request +// +// Usage: +// +// opts := CreatePullRequestOption{Body: "example"} type CreatePullRequestOption struct { - Assignee string `json:"assignee,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Base string `json:"base,omitempty"` - Body string `json:"body,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - Head string `json:"head,omitempty"` - Labels []int64 `json:"labels,omitempty"` - Milestone int64 `json:"milestone,omitempty"` - Title string `json:"title,omitempty"` + Assignee string `json:"assignee,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Base string `json:"base,omitempty"` + Body string `json:"body,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + Head string `json:"head,omitempty"` + Labels []int64 `json:"labels,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Title string `json:"title,omitempty"` } // CreatePullReviewComment — CreatePullReviewComment represent a review comment for creation api +// +// Usage: +// +// opts := CreatePullReviewComment{Body: "example"} type CreatePullReviewComment struct { - Body string `json:"body,omitempty"` - NewLineNum int64 `json:"new_position,omitempty"` // if comment to new file line or 0 - OldLineNum int64 `json:"old_position,omitempty"` // if comment to old file line or 0 - Path string `json:"path,omitempty"` // the tree path + Body string `json:"body,omitempty"` + NewLineNum int64 `json:"new_position,omitempty"` // if comment to new file line or 0 + OldLineNum int64 `json:"old_position,omitempty"` // if comment to old file line or 0 + Path string `json:"path,omitempty"` // the tree path } // CreatePullReviewCommentOptions — CreatePullReviewCommentOptions are options to create a pull review comment -// CreatePullReviewCommentOptions has no fields in the swagger spec. -type CreatePullReviewCommentOptions struct{} +// +// Usage: +// +// opts := CreatePullReviewCommentOptions(CreatePullReviewComment{}) +type CreatePullReviewCommentOptions CreatePullReviewComment // CreatePullReviewOptions — CreatePullReviewOptions are options to create a pull review +// +// Usage: +// +// opts := CreatePullReviewOptions{Body: "example"} type CreatePullReviewOptions struct { - Body string `json:"body,omitempty"` + Body string `json:"body,omitempty"` Comments []*CreatePullReviewComment `json:"comments,omitempty"` - CommitID string `json:"commit_id,omitempty"` - Event *ReviewStateType `json:"event,omitempty"` + CommitID string `json:"commit_id,omitempty"` + Event ReviewStateType `json:"event,omitempty"` } // EditPullRequestOption — EditPullRequestOption options when modify pull request +// +// Usage: +// +// opts := EditPullRequestOption{Body: "example"} type EditPullRequestOption struct { - AllowMaintainerEdit bool `json:"allow_maintainer_edit,omitempty"` - Assignee string `json:"assignee,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Base string `json:"base,omitempty"` - Body string `json:"body,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` - Milestone int64 `json:"milestone,omitempty"` - RemoveDeadline bool `json:"unset_due_date,omitempty"` - State string `json:"state,omitempty"` - Title string `json:"title,omitempty"` + AllowMaintainerEdit bool `json:"allow_maintainer_edit,omitempty"` + Assignee string `json:"assignee,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Base string `json:"base,omitempty"` + Body string `json:"body,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + Labels []int64 `json:"labels,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + RemoveDeadline bool `json:"unset_due_date,omitempty"` + State string `json:"state,omitempty"` + Title string `json:"title,omitempty"` } // PullRequest — PullRequest represents a pull request +// +// Usage: +// +// opts := PullRequest{Body: "example"} type PullRequest struct { - Additions int64 `json:"additions,omitempty"` - AllowMaintainerEdit bool `json:"allow_maintainer_edit,omitempty"` - Assignee *User `json:"assignee,omitempty"` - Assignees []*User `json:"assignees,omitempty"` - Base *PRBranchInfo `json:"base,omitempty"` - Body string `json:"body,omitempty"` - ChangedFiles int64 `json:"changed_files,omitempty"` - Closed time.Time `json:"closed_at,omitempty"` - Comments int64 `json:"comments,omitempty"` - Created time.Time `json:"created_at,omitempty"` - Deadline time.Time `json:"due_date,omitempty"` - Deletions int64 `json:"deletions,omitempty"` - DiffURL string `json:"diff_url,omitempty"` - Draft bool `json:"draft,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - HasMerged bool `json:"merged,omitempty"` - Head *PRBranchInfo `json:"head,omitempty"` - ID int64 `json:"id,omitempty"` - Index int64 `json:"number,omitempty"` - IsLocked bool `json:"is_locked,omitempty"` - Labels []*Label `json:"labels,omitempty"` - MergeBase string `json:"merge_base,omitempty"` - Mergeable bool `json:"mergeable,omitempty"` - Merged time.Time `json:"merged_at,omitempty"` - MergedBy *User `json:"merged_by,omitempty"` - MergedCommitID string `json:"merge_commit_sha,omitempty"` - Milestone *Milestone `json:"milestone,omitempty"` - PatchURL string `json:"patch_url,omitempty"` - PinOrder int64 `json:"pin_order,omitempty"` - RequestedReviewers []*User `json:"requested_reviewers,omitempty"` - RequestedReviewersTeams []*Team `json:"requested_reviewers_teams,omitempty"` - ReviewComments int64 `json:"review_comments,omitempty"` // number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) - State *StateType `json:"state,omitempty"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Additions int64 `json:"additions,omitempty"` + AllowMaintainerEdit bool `json:"allow_maintainer_edit,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Assignees []*User `json:"assignees,omitempty"` + Base *PRBranchInfo `json:"base,omitempty"` + Body string `json:"body,omitempty"` + ChangedFiles int64 `json:"changed_files,omitempty"` + Closed time.Time `json:"closed_at,omitempty"` + Comments int64 `json:"comments,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Deadline time.Time `json:"due_date,omitempty"` + Deletions int64 `json:"deletions,omitempty"` + DiffURL string `json:"diff_url,omitempty"` + Draft bool `json:"draft,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + HasMerged bool `json:"merged,omitempty"` + Head *PRBranchInfo `json:"head,omitempty"` + ID int64 `json:"id,omitempty"` + Index int64 `json:"number,omitempty"` + IsLocked bool `json:"is_locked,omitempty"` + Labels []*Label `json:"labels,omitempty"` + MergeBase string `json:"merge_base,omitempty"` + Mergeable bool `json:"mergeable,omitempty"` + Merged time.Time `json:"merged_at,omitempty"` + MergedBy *User `json:"merged_by,omitempty"` + MergedCommitID string `json:"merge_commit_sha,omitempty"` + Milestone *Milestone `json:"milestone,omitempty"` + PatchURL string `json:"patch_url,omitempty"` + PinOrder int64 `json:"pin_order,omitempty"` + RequestedReviewers []*User `json:"requested_reviewers,omitempty"` + RequestedReviewersTeams []*Team `json:"requested_reviewers_teams,omitempty"` + ReviewComments int64 `json:"review_comments,omitempty"` // number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) + State StateType `json:"state,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } // PullRequestMeta — PullRequestMeta PR info if an issue is a PR +// +// Usage: +// +// opts := PullRequestMeta{HTMLURL: "https://example.com"} type PullRequestMeta struct { - HTMLURL string `json:"html_url,omitempty"` - HasMerged bool `json:"merged,omitempty"` - IsWorkInProgress bool `json:"draft,omitempty"` - Merged time.Time `json:"merged_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + HasMerged bool `json:"merged,omitempty"` + IsWorkInProgress bool `json:"draft,omitempty"` + Merged time.Time `json:"merged_at,omitempty"` } // PullReview — PullReview represents a pull request review +// +// Usage: +// +// opts := PullReview{Body: "example"} type PullReview struct { - Body string `json:"body,omitempty"` - CodeCommentsCount int64 `json:"comments_count,omitempty"` - CommitID string `json:"commit_id,omitempty"` - Dismissed bool `json:"dismissed,omitempty"` - HTMLPullURL string `json:"pull_request_url,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - Official bool `json:"official,omitempty"` - Stale bool `json:"stale,omitempty"` - State *ReviewStateType `json:"state,omitempty"` - Submitted time.Time `json:"submitted_at,omitempty"` - Team *Team `json:"team,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Body string `json:"body,omitempty"` + CodeCommentsCount int64 `json:"comments_count,omitempty"` + CommitID string `json:"commit_id,omitempty"` + Dismissed bool `json:"dismissed,omitempty"` + HTMLPullURL string `json:"pull_request_url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + Official bool `json:"official,omitempty"` + Stale bool `json:"stale,omitempty"` + State ReviewStateType `json:"state,omitempty"` + Submitted time.Time `json:"submitted_at,omitempty"` + Team *Team `json:"team,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } // PullReviewComment — PullReviewComment represents a comment on a pull request review +// +// Usage: +// +// opts := PullReviewComment{Body: "example"} type PullReviewComment struct { - Body string `json:"body,omitempty"` - CommitID string `json:"commit_id,omitempty"` - Created time.Time `json:"created_at,omitempty"` - DiffHunk string `json:"diff_hunk,omitempty"` - HTMLPullURL string `json:"pull_request_url,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - ID int64 `json:"id,omitempty"` - LineNum int `json:"position,omitempty"` - OldLineNum int `json:"original_position,omitempty"` - OrigCommitID string `json:"original_commit_id,omitempty"` - Path string `json:"path,omitempty"` - Resolver *User `json:"resolver,omitempty"` - ReviewID int64 `json:"pull_request_review_id,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - User *User `json:"user,omitempty"` + Body string `json:"body,omitempty"` + CommitID string `json:"commit_id,omitempty"` + Created time.Time `json:"created_at,omitempty"` + DiffHunk string `json:"diff_hunk,omitempty"` + HTMLPullURL string `json:"pull_request_url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + ID int64 `json:"id,omitempty"` + LineNum int `json:"position,omitempty"` + OldLineNum int `json:"original_position,omitempty"` + OrigCommitID string `json:"original_commit_id,omitempty"` + Path string `json:"path,omitempty"` + Resolver *User `json:"resolver,omitempty"` + ReviewID int64 `json:"pull_request_review_id,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + User *User `json:"user,omitempty"` } // PullReviewRequestOptions — PullReviewRequestOptions are options to add or remove pull review requests +// +// Usage: +// +// opts := PullReviewRequestOptions{Reviewers: []string{"example"}} type PullReviewRequestOptions struct { - Reviewers []string `json:"reviewers,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` TeamReviewers []string `json:"team_reviewers,omitempty"` } // SubmitPullReviewOptions — SubmitPullReviewOptions are options to submit a pending pull review +// +// Usage: +// +// opts := SubmitPullReviewOptions{Body: "example"} type SubmitPullReviewOptions struct { - Body string `json:"body,omitempty"` - Event *ReviewStateType `json:"event,omitempty"` + Body string `json:"body,omitempty"` + Event ReviewStateType `json:"event,omitempty"` } - diff --git a/types/quota.go b/types/quota.go index 7d9353c..c5e5d08 100644 --- a/types/quota.go +++ b/types/quota.go @@ -2,123 +2,197 @@ package types - // CreateQuotaGroupOptions — CreateQutaGroupOptions represents the options for creating a quota group +// +// Usage: +// +// opts := CreateQuotaGroupOptions{Name: "example"} type CreateQuotaGroupOptions struct { - Name string `json:"name,omitempty"` // Name of the quota group to create + Name string `json:"name,omitempty"` // Name of the quota group to create Rules []*CreateQuotaRuleOptions `json:"rules,omitempty"` // Rules to add to the newly created group. If a rule does not exist, it will be created. } // CreateQuotaRuleOptions — CreateQuotaRuleOptions represents the options for creating a quota rule +// +// Usage: +// +// opts := CreateQuotaRuleOptions{Name: "example"} type CreateQuotaRuleOptions struct { - Limit int64 `json:"limit,omitempty"` // The limit set by the rule - Name string `json:"name,omitempty"` // Name of the rule to create + Limit int64 `json:"limit,omitempty"` // The limit set by the rule + Name string `json:"name,omitempty"` // Name of the rule to create Subjects []string `json:"subjects,omitempty"` // The subjects affected by the rule } // EditQuotaRuleOptions — EditQuotaRuleOptions represents the options for editing a quota rule +// +// Usage: +// +// opts := EditQuotaRuleOptions{Subjects: []string{"example"}} type EditQuotaRuleOptions struct { - Limit int64 `json:"limit,omitempty"` // The limit set by the rule + Limit int64 `json:"limit,omitempty"` // The limit set by the rule Subjects []string `json:"subjects,omitempty"` // The subjects affected by the rule } // QuotaGroup — QuotaGroup represents a quota group +// +// Usage: +// +// opts := QuotaGroup{Name: "example"} type QuotaGroup struct { - Name string `json:"name,omitempty"` // Name of the group + Name string `json:"name,omitempty"` // Name of the group Rules []*QuotaRuleInfo `json:"rules,omitempty"` // Rules associated with the group } // QuotaGroupList — QuotaGroupList represents a list of quota groups -// QuotaGroupList has no fields in the swagger spec. -type QuotaGroupList struct{} +// +// Usage: +// +// opts := QuotaGroupList([]*QuotaGroup{}) +type QuotaGroupList []*QuotaGroup // QuotaInfo — QuotaInfo represents information about a user's quota +// +// Usage: +// +// opts := QuotaInfo{Groups: {}} type QuotaInfo struct { - Groups *QuotaGroupList `json:"groups,omitempty"` - Used *QuotaUsed `json:"used,omitempty"` + Groups QuotaGroupList `json:"groups,omitempty"` + Used *QuotaUsed `json:"used,omitempty"` } // QuotaRuleInfo — QuotaRuleInfo contains information about a quota rule +// +// Usage: +// +// opts := QuotaRuleInfo{Name: "example"} type QuotaRuleInfo struct { - Limit int64 `json:"limit,omitempty"` // The limit set by the rule - Name string `json:"name,omitempty"` // Name of the rule (only shown to admins) + Limit int64 `json:"limit,omitempty"` // The limit set by the rule + Name string `json:"name,omitempty"` // Name of the rule (only shown to admins) Subjects []string `json:"subjects,omitempty"` // Subjects the rule affects } // QuotaUsed — QuotaUsed represents the quota usage of a user +// +// Usage: +// +// opts := QuotaUsed{Size: &QuotaUsedSize{}} type QuotaUsed struct { Size *QuotaUsedSize `json:"size,omitempty"` } // QuotaUsedArtifact — QuotaUsedArtifact represents an artifact counting towards a user's quota +// +// Usage: +// +// opts := QuotaUsedArtifact{Name: "example"} type QuotaUsedArtifact struct { HTMLURL string `json:"html_url,omitempty"` // HTML URL to the action run containing the artifact - Name string `json:"name,omitempty"` // Name of the artifact - Size int64 `json:"size,omitempty"` // Size of the artifact (compressed) + Name string `json:"name,omitempty"` // Name of the artifact + Size int64 `json:"size,omitempty"` // Size of the artifact (compressed) } // QuotaUsedArtifactList — QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota -// QuotaUsedArtifactList has no fields in the swagger spec. -type QuotaUsedArtifactList struct{} +// +// Usage: +// +// opts := QuotaUsedArtifactList([]*QuotaUsedArtifact{}) +type QuotaUsedArtifactList []*QuotaUsedArtifact // QuotaUsedAttachment — QuotaUsedAttachment represents an attachment counting towards a user's quota +// +// Usage: +// +// opts := QuotaUsedAttachment{Name: "example"} type QuotaUsedAttachment struct { - APIURL string `json:"api_url,omitempty"` // API URL for the attachment + APIURL string `json:"api_url,omitempty"` // API URL for the attachment ContainedIn map[string]any `json:"contained_in,omitempty"` // Context for the attachment: URLs to the containing object - Name string `json:"name,omitempty"` // Filename of the attachment - Size int64 `json:"size,omitempty"` // Size of the attachment (in bytes) + Name string `json:"name,omitempty"` // Filename of the attachment + Size int64 `json:"size,omitempty"` // Size of the attachment (in bytes) } // QuotaUsedAttachmentList — QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota -// QuotaUsedAttachmentList has no fields in the swagger spec. -type QuotaUsedAttachmentList struct{} +// +// Usage: +// +// opts := QuotaUsedAttachmentList([]*QuotaUsedAttachment{}) +type QuotaUsedAttachmentList []*QuotaUsedAttachment // QuotaUsedPackage — QuotaUsedPackage represents a package counting towards a user's quota +// +// Usage: +// +// opts := QuotaUsedPackage{Name: "example"} type QuotaUsedPackage struct { HTMLURL string `json:"html_url,omitempty"` // HTML URL to the package version - Name string `json:"name,omitempty"` // Name of the package - Size int64 `json:"size,omitempty"` // Size of the package version - Type string `json:"type,omitempty"` // Type of the package - Version string `json:"version,omitempty"` // Version of the package + Name string `json:"name,omitempty"` // Name of the package + Size int64 `json:"size,omitempty"` // Size of the package version + Type string `json:"type,omitempty"` // Type of the package + Version string `json:"version,omitempty"` // Version of the package } // QuotaUsedPackageList — QuotaUsedPackageList represents a list of packages counting towards a user's quota -// QuotaUsedPackageList has no fields in the swagger spec. -type QuotaUsedPackageList struct{} +// +// Usage: +// +// opts := QuotaUsedPackageList([]*QuotaUsedPackage{}) +type QuotaUsedPackageList []*QuotaUsedPackage // QuotaUsedSize — QuotaUsedSize represents the size-based quota usage of a user +// +// Usage: +// +// opts := QuotaUsedSize{Assets: &QuotaUsedSizeAssets{}} type QuotaUsedSize struct { Assets *QuotaUsedSizeAssets `json:"assets,omitempty"` - Git *QuotaUsedSizeGit `json:"git,omitempty"` - Repos *QuotaUsedSizeRepos `json:"repos,omitempty"` + Git *QuotaUsedSizeGit `json:"git,omitempty"` + Repos *QuotaUsedSizeRepos `json:"repos,omitempty"` } // QuotaUsedSizeAssets — QuotaUsedSizeAssets represents the size-based asset usage of a user +// +// Usage: +// +// opts := QuotaUsedSizeAssets{Artifacts: 1} type QuotaUsedSizeAssets struct { - Artifacts int64 `json:"artifacts,omitempty"` // Storage size used for the user's artifacts + Artifacts int64 `json:"artifacts,omitempty"` // Storage size used for the user's artifacts Attachments *QuotaUsedSizeAssetsAttachments `json:"attachments,omitempty"` - Packages *QuotaUsedSizeAssetsPackages `json:"packages,omitempty"` + Packages *QuotaUsedSizeAssetsPackages `json:"packages,omitempty"` } // QuotaUsedSizeAssetsAttachments — QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user +// +// Usage: +// +// opts := QuotaUsedSizeAssetsAttachments{Issues: 1} type QuotaUsedSizeAssetsAttachments struct { - Issues int64 `json:"issues,omitempty"` // Storage size used for the user's issue & comment attachments + Issues int64 `json:"issues,omitempty"` // Storage size used for the user's issue & comment attachments Releases int64 `json:"releases,omitempty"` // Storage size used for the user's release attachments } // QuotaUsedSizeAssetsPackages — QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user +// +// Usage: +// +// opts := QuotaUsedSizeAssetsPackages{All: 1} type QuotaUsedSizeAssetsPackages struct { All int64 `json:"all,omitempty"` // Storage suze used for the user's packages } // QuotaUsedSizeGit — QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user +// +// Usage: +// +// opts := QuotaUsedSizeGit{LFS: 1} type QuotaUsedSizeGit struct { LFS int64 `json:"LFS,omitempty"` // Storage size of the user's Git LFS objects } // QuotaUsedSizeRepos — QuotaUsedSizeRepos represents the size-based repository quota usage of a user +// +// Usage: +// +// opts := QuotaUsedSizeRepos{Private: 1} type QuotaUsedSizeRepos struct { Private int64 `json:"private,omitempty"` // Storage size of the user's private repositories - Public int64 `json:"public,omitempty"` // Storage size of the user's public repositories + Public int64 `json:"public,omitempty"` // Storage size of the user's public repositories } - diff --git a/types/reaction.go b/types/reaction.go index 950c72d..a4ca981 100644 --- a/types/reaction.go +++ b/types/reaction.go @@ -4,16 +4,22 @@ package types import "time" - // EditReactionOption — EditReactionOption contain the reaction type +// +// Usage: +// +// opts := EditReactionOption{Reaction: "example"} type EditReactionOption struct { Reaction string `json:"content,omitempty"` } // Reaction — Reaction contain one reaction +// +// Usage: +// +// opts := Reaction{Reaction: "example"} type Reaction struct { - Created time.Time `json:"created_at,omitempty"` - Reaction string `json:"content,omitempty"` - User *User `json:"user,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Reaction string `json:"content,omitempty"` + User *User `json:"user,omitempty"` } - diff --git a/types/release.go b/types/release.go index 9dad9d8..7270456 100644 --- a/types/release.go +++ b/types/release.go @@ -4,48 +4,58 @@ package types import "time" - // CreateReleaseOption — CreateReleaseOption options when creating a release +// +// Usage: +// +// opts := CreateReleaseOption{TagName: "v1.0.0"} type CreateReleaseOption struct { - HideArchiveLinks bool `json:"hide_archive_links,omitempty"` - IsDraft bool `json:"draft,omitempty"` - IsPrerelease bool `json:"prerelease,omitempty"` - Note string `json:"body,omitempty"` - TagName string `json:"tag_name"` - Target string `json:"target_commitish,omitempty"` - Title string `json:"name,omitempty"` + HideArchiveLinks bool `json:"hide_archive_links,omitempty"` + IsDraft bool `json:"draft,omitempty"` + IsPrerelease bool `json:"prerelease,omitempty"` + Note string `json:"body,omitempty"` + TagName string `json:"tag_name"` + Target string `json:"target_commitish,omitempty"` + Title string `json:"name,omitempty"` } // EditReleaseOption — EditReleaseOption options when editing a release +// +// Usage: +// +// opts := EditReleaseOption{TagName: "v1.0.0"} type EditReleaseOption struct { - HideArchiveLinks bool `json:"hide_archive_links,omitempty"` - IsDraft bool `json:"draft,omitempty"` - IsPrerelease bool `json:"prerelease,omitempty"` - Note string `json:"body,omitempty"` - TagName string `json:"tag_name,omitempty"` - Target string `json:"target_commitish,omitempty"` - Title string `json:"name,omitempty"` + HideArchiveLinks bool `json:"hide_archive_links,omitempty"` + IsDraft bool `json:"draft,omitempty"` + IsPrerelease bool `json:"prerelease,omitempty"` + Note string `json:"body,omitempty"` + TagName string `json:"tag_name,omitempty"` + Target string `json:"target_commitish,omitempty"` + Title string `json:"name,omitempty"` } // Release — Release represents a repository release +// +// Usage: +// +// opts := Release{TagName: "v1.0.0"} type Release struct { ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count,omitempty"` - Attachments []*Attachment `json:"assets,omitempty"` - Author *User `json:"author,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - HideArchiveLinks bool `json:"hide_archive_links,omitempty"` - ID int64 `json:"id,omitempty"` - IsDraft bool `json:"draft,omitempty"` - IsPrerelease bool `json:"prerelease,omitempty"` - Note string `json:"body,omitempty"` - PublishedAt time.Time `json:"published_at,omitempty"` - TagName string `json:"tag_name,omitempty"` - TarURL string `json:"tarball_url,omitempty"` - Target string `json:"target_commitish,omitempty"` - Title string `json:"name,omitempty"` - URL string `json:"url,omitempty"` - UploadURL string `json:"upload_url,omitempty"` - ZipURL string `json:"zipball_url,omitempty"` + Attachments []*Attachment `json:"assets,omitempty"` + Author *User `json:"author,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + HideArchiveLinks bool `json:"hide_archive_links,omitempty"` + ID int64 `json:"id,omitempty"` + IsDraft bool `json:"draft,omitempty"` + IsPrerelease bool `json:"prerelease,omitempty"` + Note string `json:"body,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + TagName string `json:"tag_name,omitempty"` + TarURL string `json:"tarball_url,omitempty"` + Target string `json:"target_commitish,omitempty"` + Title string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + UploadURL string `json:"upload_url,omitempty"` + ZipURL string `json:"zipball_url,omitempty"` } - diff --git a/types/repo.go b/types/repo.go index 48951de..b51392a 100644 --- a/types/repo.go +++ b/types/repo.go @@ -4,214 +4,270 @@ package types import "time" - +// Usage: +// +// opts := CreatePushMirrorOption{Interval: "example"} type CreatePushMirrorOption struct { - Interval string `json:"interval,omitempty"` - RemoteAddress string `json:"remote_address,omitempty"` + Interval string `json:"interval,omitempty"` + RemoteAddress string `json:"remote_address,omitempty"` RemotePassword string `json:"remote_password,omitempty"` RemoteUsername string `json:"remote_username,omitempty"` - SyncOnCommit bool `json:"sync_on_commit,omitempty"` - UseSSH bool `json:"use_ssh,omitempty"` + SyncOnCommit bool `json:"sync_on_commit,omitempty"` + UseSSH bool `json:"use_ssh,omitempty"` } // CreateRepoOption — CreateRepoOption options when creating repository +// +// Usage: +// +// opts := CreateRepoOption{Name: "example"} type CreateRepoOption struct { - AutoInit bool `json:"auto_init,omitempty"` // Whether the repository should be auto-initialized? - DefaultBranch string `json:"default_branch,omitempty"` // DefaultBranch of the repository (used when initializes and in template) - Description string `json:"description,omitempty"` // Description of the repository to create - Gitignores string `json:"gitignores,omitempty"` // Gitignores to use - IssueLabels string `json:"issue_labels,omitempty"` // Label-Set to use - License string `json:"license,omitempty"` // License to use - Name string `json:"name"` // Name of the repository to create + AutoInit bool `json:"auto_init,omitempty"` // Whether the repository should be auto-initialized? + DefaultBranch string `json:"default_branch,omitempty"` // DefaultBranch of the repository (used when initializes and in template) + Description string `json:"description,omitempty"` // Description of the repository to create + Gitignores string `json:"gitignores,omitempty"` // Gitignores to use + IssueLabels string `json:"issue_labels,omitempty"` // Label-Set to use + License string `json:"license,omitempty"` // License to use + Name string `json:"name"` // Name of the repository to create ObjectFormatName string `json:"object_format_name,omitempty"` // ObjectFormatName of the underlying git repository - Private bool `json:"private,omitempty"` // Whether the repository is private - Readme string `json:"readme,omitempty"` // Readme of the repository to create - Template bool `json:"template,omitempty"` // Whether the repository is template - TrustModel string `json:"trust_model,omitempty"` // TrustModel of the repository + Private bool `json:"private,omitempty"` // Whether the repository is private + Readme string `json:"readme,omitempty"` // Readme of the repository to create + Template bool `json:"template,omitempty"` // Whether the repository is template + TrustModel string `json:"trust_model,omitempty"` // TrustModel of the repository } // EditRepoOption — EditRepoOption options when editing a repository's properties +// +// Usage: +// +// opts := EditRepoOption{Description: "example"} type EditRepoOption struct { - AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge,omitempty"` // either `true` to allow fast-forward-only merging pull requests, or `false` to prevent fast-forward-only merging. - AllowManualMerge bool `json:"allow_manual_merge,omitempty"` // either `true` to allow mark pr as merged manually, or `false` to prevent it. - AllowMerge bool `json:"allow_merge_commits,omitempty"` // either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. - AllowRebase bool `json:"allow_rebase,omitempty"` // either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging. - AllowRebaseMerge bool `json:"allow_rebase_explicit,omitempty"` // either `true` to allow rebase with explicit merge commits (--no-ff), or `false` to prevent rebase with explicit merge commits. - AllowRebaseUpdate bool `json:"allow_rebase_update,omitempty"` // either `true` to allow updating pull request branch by rebase, or `false` to prevent it. - AllowSquash bool `json:"allow_squash_merge,omitempty"` // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. - Archived bool `json:"archived,omitempty"` // set to `true` to archive this repository. - AutodetectManualMerge bool `json:"autodetect_manual_merge,omitempty"` // either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur. - DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit,omitempty"` // set to `true` to allow edits from maintainers by default - DefaultBranch string `json:"default_branch,omitempty"` // sets the default branch for this repository. - DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge,omitempty"` // set to `true` to delete pr branch after merge by default - DefaultMergeStyle string `json:"default_merge_style,omitempty"` // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only". - DefaultUpdateStyle string `json:"default_update_style,omitempty"` // set to a update style to be used by this repository: "rebase" or "merge" - Description string `json:"description,omitempty"` // a short description of the repository. - EnablePrune bool `json:"enable_prune,omitempty"` // enable prune - remove obsolete remote-tracking references when mirroring - ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` - ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` - GloballyEditableWiki bool `json:"globally_editable_wiki,omitempty"` // set the globally editable state of the wiki - HasActions bool `json:"has_actions,omitempty"` // either `true` to enable actions unit, or `false` to disable them. - HasIssues bool `json:"has_issues,omitempty"` // either `true` to enable issues for this repository or `false` to disable them. - HasPackages bool `json:"has_packages,omitempty"` // either `true` to enable packages unit, or `false` to disable them. - HasProjects bool `json:"has_projects,omitempty"` // either `true` to enable project unit, or `false` to disable them. - HasPullRequests bool `json:"has_pull_requests,omitempty"` // either `true` to allow pull requests, or `false` to prevent pull request. - HasReleases bool `json:"has_releases,omitempty"` // either `true` to enable releases unit, or `false` to disable them. - HasWiki bool `json:"has_wiki,omitempty"` // either `true` to enable the wiki for this repository or `false` to disable it. - IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts,omitempty"` // either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace. - InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` - MirrorInterval string `json:"mirror_interval,omitempty"` // set to a string like `8h30m0s` to set the mirror interval time - Name string `json:"name,omitempty"` // name of the repository - Private bool `json:"private,omitempty"` // either `true` to make the repository private or `false` to make it public. Note: you will get a 422 error if the organization restricts changing repository visibility to organization owners and a non-owner tries to change the value of private. - Template bool `json:"template,omitempty"` // either `true` to make this repository a template or `false` to make it a normal repository - Website string `json:"website,omitempty"` // a URL with more information about the repository. - WikiBranch string `json:"wiki_branch,omitempty"` // sets the branch used for this repository's wiki. + AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge,omitempty"` // either `true` to allow fast-forward-only merging pull requests, or `false` to prevent fast-forward-only merging. + AllowManualMerge bool `json:"allow_manual_merge,omitempty"` // either `true` to allow mark pr as merged manually, or `false` to prevent it. + AllowMerge bool `json:"allow_merge_commits,omitempty"` // either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. + AllowRebase bool `json:"allow_rebase,omitempty"` // either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging. + AllowRebaseMerge bool `json:"allow_rebase_explicit,omitempty"` // either `true` to allow rebase with explicit merge commits (--no-ff), or `false` to prevent rebase with explicit merge commits. + AllowRebaseUpdate bool `json:"allow_rebase_update,omitempty"` // either `true` to allow updating pull request branch by rebase, or `false` to prevent it. + AllowSquash bool `json:"allow_squash_merge,omitempty"` // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. + Archived bool `json:"archived,omitempty"` // set to `true` to archive this repository. + AutodetectManualMerge bool `json:"autodetect_manual_merge,omitempty"` // either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur. + DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit,omitempty"` // set to `true` to allow edits from maintainers by default + DefaultBranch string `json:"default_branch,omitempty"` // sets the default branch for this repository. + DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge,omitempty"` // set to `true` to delete pr branch after merge by default + DefaultMergeStyle string `json:"default_merge_style,omitempty"` // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only". + DefaultUpdateStyle string `json:"default_update_style,omitempty"` // set to a update style to be used by this repository: "rebase" or "merge" + Description string `json:"description,omitempty"` // a short description of the repository. + EnablePrune bool `json:"enable_prune,omitempty"` // enable prune - remove obsolete remote-tracking references when mirroring + ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` + ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` + GloballyEditableWiki bool `json:"globally_editable_wiki,omitempty"` // set the globally editable state of the wiki + HasActions bool `json:"has_actions,omitempty"` // either `true` to enable actions unit, or `false` to disable them. + HasIssues bool `json:"has_issues,omitempty"` // either `true` to enable issues for this repository or `false` to disable them. + HasPackages bool `json:"has_packages,omitempty"` // either `true` to enable packages unit, or `false` to disable them. + HasProjects bool `json:"has_projects,omitempty"` // either `true` to enable project unit, or `false` to disable them. + HasPullRequests bool `json:"has_pull_requests,omitempty"` // either `true` to allow pull requests, or `false` to prevent pull request. + HasReleases bool `json:"has_releases,omitempty"` // either `true` to enable releases unit, or `false` to disable them. + HasWiki bool `json:"has_wiki,omitempty"` // either `true` to enable the wiki for this repository or `false` to disable it. + IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts,omitempty"` // either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace. + InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` + MirrorInterval string `json:"mirror_interval,omitempty"` // set to a string like `8h30m0s` to set the mirror interval time + Name string `json:"name,omitempty"` // name of the repository + Private bool `json:"private,omitempty"` // either `true` to make the repository private or `false` to make it public. Note: you will get a 422 error if the organization restricts changing repository visibility to organization owners and a non-owner tries to change the value of private. + Template bool `json:"template,omitempty"` // either `true` to make this repository a template or `false` to make it a normal repository + Website string `json:"website,omitempty"` // a URL with more information about the repository. + WikiBranch string `json:"wiki_branch,omitempty"` // sets the branch used for this repository's wiki. } // ExternalTracker — ExternalTracker represents settings for external tracker +// +// Usage: +// +// opts := ExternalTracker{ExternalTrackerFormat: "example"} type ExternalTracker struct { - ExternalTrackerFormat string `json:"external_tracker_format,omitempty"` // External Issue Tracker URL Format. Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index. + ExternalTrackerFormat string `json:"external_tracker_format,omitempty"` // External Issue Tracker URL Format. Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index. ExternalTrackerRegexpPattern string `json:"external_tracker_regexp_pattern,omitempty"` // External Issue Tracker issue regular expression - ExternalTrackerStyle string `json:"external_tracker_style,omitempty"` // External Issue Tracker Number Format, either `numeric`, `alphanumeric`, or `regexp` - ExternalTrackerURL string `json:"external_tracker_url,omitempty"` // URL of external issue tracker. + ExternalTrackerStyle string `json:"external_tracker_style,omitempty"` // External Issue Tracker Number Format, either `numeric`, `alphanumeric`, or `regexp` + ExternalTrackerURL string `json:"external_tracker_url,omitempty"` // URL of external issue tracker. } // ExternalWiki — ExternalWiki represents setting for external wiki +// +// Usage: +// +// opts := ExternalWiki{ExternalWikiURL: "https://example.com"} type ExternalWiki struct { ExternalWikiURL string `json:"external_wiki_url,omitempty"` // URL of external wiki. } // InternalTracker — InternalTracker represents settings for internal tracker +// +// Usage: +// +// opts := InternalTracker{AllowOnlyContributorsToTrackTime: true} type InternalTracker struct { AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time,omitempty"` // Let only contributors track time (Built-in issue tracker) - EnableIssueDependencies bool `json:"enable_issue_dependencies,omitempty"` // Enable dependencies for issues and pull requests (Built-in issue tracker) - EnableTimeTracker bool `json:"enable_time_tracker,omitempty"` // Enable time tracking (Built-in issue tracker) + EnableIssueDependencies bool `json:"enable_issue_dependencies,omitempty"` // Enable dependencies for issues and pull requests (Built-in issue tracker) + EnableTimeTracker bool `json:"enable_time_tracker,omitempty"` // Enable time tracking (Built-in issue tracker) } // PushMirror — PushMirror represents information of a push mirror +// +// Usage: +// +// opts := PushMirror{RemoteName: "example"} type PushMirror struct { - CreatedUnix time.Time `json:"created,omitempty"` - Interval string `json:"interval,omitempty"` - LastError string `json:"last_error,omitempty"` + CreatedUnix time.Time `json:"created,omitempty"` + Interval string `json:"interval,omitempty"` + LastError string `json:"last_error,omitempty"` LastUpdateUnix time.Time `json:"last_update,omitempty"` - PublicKey string `json:"public_key,omitempty"` - RemoteAddress string `json:"remote_address,omitempty"` - RemoteName string `json:"remote_name,omitempty"` - RepoName string `json:"repo_name,omitempty"` - SyncOnCommit bool `json:"sync_on_commit,omitempty"` + PublicKey string `json:"public_key,omitempty"` + RemoteAddress string `json:"remote_address,omitempty"` + RemoteName string `json:"remote_name,omitempty"` + RepoName string `json:"repo_name,omitempty"` + SyncOnCommit bool `json:"sync_on_commit,omitempty"` } // RepoCollaboratorPermission — RepoCollaboratorPermission to get repository permission for a collaborator +// +// Usage: +// +// opts := RepoCollaboratorPermission{RoleName: "example"} type RepoCollaboratorPermission struct { Permission string `json:"permission,omitempty"` - RoleName string `json:"role_name,omitempty"` - User *User `json:"user,omitempty"` + RoleName string `json:"role_name,omitempty"` + User *User `json:"user,omitempty"` } +// Usage: +// +// opts := RepoCommit{Message: "example"} type RepoCommit struct { - Author *CommitUser `json:"author,omitempty"` - Committer *CommitUser `json:"committer,omitempty"` - Message string `json:"message,omitempty"` - Tree *CommitMeta `json:"tree,omitempty"` - URL string `json:"url,omitempty"` + Author *CommitUser `json:"author,omitempty"` + Committer *CommitUser `json:"committer,omitempty"` + Message string `json:"message,omitempty"` + Tree *CommitMeta `json:"tree,omitempty"` + URL string `json:"url,omitempty"` Verification *PayloadCommitVerification `json:"verification,omitempty"` } // RepoTopicOptions — RepoTopicOptions a collection of repo topic names +// +// Usage: +// +// opts := RepoTopicOptions{Topics: []string{"example"}} type RepoTopicOptions struct { Topics []string `json:"topics,omitempty"` // list of topic names } // RepoTransfer — RepoTransfer represents a pending repo transfer +// +// Usage: +// +// opts := RepoTransfer{Teams: {}} type RepoTransfer struct { - Doer *User `json:"doer,omitempty"` - Recipient *User `json:"recipient,omitempty"` - Teams []*Team `json:"teams,omitempty"` + Doer *User `json:"doer,omitempty"` + Recipient *User `json:"recipient,omitempty"` + Teams []*Team `json:"teams,omitempty"` } // Repository — Repository represents a repository +// +// Usage: +// +// opts := Repository{Description: "example"} type Repository struct { - AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge,omitempty"` - AllowMerge bool `json:"allow_merge_commits,omitempty"` - AllowRebase bool `json:"allow_rebase,omitempty"` - AllowRebaseMerge bool `json:"allow_rebase_explicit,omitempty"` - AllowRebaseUpdate bool `json:"allow_rebase_update,omitempty"` - AllowSquash bool `json:"allow_squash_merge,omitempty"` - Archived bool `json:"archived,omitempty"` - ArchivedAt time.Time `json:"archived_at,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - CloneURL string `json:"clone_url,omitempty"` - Created time.Time `json:"created_at,omitempty"` - DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit,omitempty"` - DefaultBranch string `json:"default_branch,omitempty"` - DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge,omitempty"` - DefaultMergeStyle string `json:"default_merge_style,omitempty"` - DefaultUpdateStyle string `json:"default_update_style,omitempty"` - Description string `json:"description,omitempty"` - Empty bool `json:"empty,omitempty"` - ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` - ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` - Fork bool `json:"fork,omitempty"` - Forks int64 `json:"forks_count,omitempty"` - FullName string `json:"full_name,omitempty"` - GloballyEditableWiki bool `json:"globally_editable_wiki,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - HasActions bool `json:"has_actions,omitempty"` - HasIssues bool `json:"has_issues,omitempty"` - HasPackages bool `json:"has_packages,omitempty"` - HasProjects bool `json:"has_projects,omitempty"` - HasPullRequests bool `json:"has_pull_requests,omitempty"` - HasReleases bool `json:"has_releases,omitempty"` - HasWiki bool `json:"has_wiki,omitempty"` - ID int64 `json:"id,omitempty"` - IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts,omitempty"` - Internal bool `json:"internal,omitempty"` - InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` - Language string `json:"language,omitempty"` - LanguagesURL string `json:"languages_url,omitempty"` - Link string `json:"link,omitempty"` - Mirror bool `json:"mirror,omitempty"` - MirrorInterval string `json:"mirror_interval,omitempty"` - MirrorUpdated time.Time `json:"mirror_updated,omitempty"` - Name string `json:"name,omitempty"` - ObjectFormatName string `json:"object_format_name,omitempty"` // ObjectFormatName of the underlying git repository - OpenIssues int64 `json:"open_issues_count,omitempty"` - OpenPulls int64 `json:"open_pr_counter,omitempty"` - OriginalURL string `json:"original_url,omitempty"` - Owner *User `json:"owner,omitempty"` - Parent *Repository `json:"parent,omitempty"` - Permissions *Permission `json:"permissions,omitempty"` - Private bool `json:"private,omitempty"` - Releases int64 `json:"release_counter,omitempty"` - RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"` - SSHURL string `json:"ssh_url,omitempty"` - Size int64 `json:"size,omitempty"` - Stars int64 `json:"stars_count,omitempty"` - Template bool `json:"template,omitempty"` - Topics []string `json:"topics,omitempty"` - URL string `json:"url,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - Watchers int64 `json:"watchers_count,omitempty"` - Website string `json:"website,omitempty"` - WikiBranch string `json:"wiki_branch,omitempty"` + AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge,omitempty"` + AllowMerge bool `json:"allow_merge_commits,omitempty"` + AllowRebase bool `json:"allow_rebase,omitempty"` + AllowRebaseMerge bool `json:"allow_rebase_explicit,omitempty"` + AllowRebaseUpdate bool `json:"allow_rebase_update,omitempty"` + AllowSquash bool `json:"allow_squash_merge,omitempty"` + Archived bool `json:"archived,omitempty"` + ArchivedAt time.Time `json:"archived_at,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + CloneURL string `json:"clone_url,omitempty"` + Created time.Time `json:"created_at,omitempty"` + DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge,omitempty"` + DefaultMergeStyle string `json:"default_merge_style,omitempty"` + DefaultUpdateStyle string `json:"default_update_style,omitempty"` + Description string `json:"description,omitempty"` + Empty bool `json:"empty,omitempty"` + ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` + ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` + Fork bool `json:"fork,omitempty"` + Forks int64 `json:"forks_count,omitempty"` + FullName string `json:"full_name,omitempty"` + GloballyEditableWiki bool `json:"globally_editable_wiki,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + HasActions bool `json:"has_actions,omitempty"` + HasIssues bool `json:"has_issues,omitempty"` + HasPackages bool `json:"has_packages,omitempty"` + HasProjects bool `json:"has_projects,omitempty"` + HasPullRequests bool `json:"has_pull_requests,omitempty"` + HasReleases bool `json:"has_releases,omitempty"` + HasWiki bool `json:"has_wiki,omitempty"` + ID int64 `json:"id,omitempty"` + IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts,omitempty"` + Internal bool `json:"internal,omitempty"` + InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` + Language string `json:"language,omitempty"` + LanguagesURL string `json:"languages_url,omitempty"` + Link string `json:"link,omitempty"` + Mirror bool `json:"mirror,omitempty"` + MirrorInterval string `json:"mirror_interval,omitempty"` + MirrorUpdated time.Time `json:"mirror_updated,omitempty"` + Name string `json:"name,omitempty"` + ObjectFormatName string `json:"object_format_name,omitempty"` // ObjectFormatName of the underlying git repository + OpenIssues int64 `json:"open_issues_count,omitempty"` + OpenPulls int64 `json:"open_pr_counter,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + Owner *User `json:"owner,omitempty"` + Parent *Repository `json:"parent,omitempty"` + Permissions *Permission `json:"permissions,omitempty"` + Private bool `json:"private,omitempty"` + Releases int64 `json:"release_counter,omitempty"` + RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"` + SSHURL string `json:"ssh_url,omitempty"` + Size int64 `json:"size,omitempty"` + Stars int64 `json:"stars_count,omitempty"` + Template bool `json:"template,omitempty"` + Topics []string `json:"topics,omitempty"` + URL string `json:"url,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + Watchers int64 `json:"watchers_count,omitempty"` + Website string `json:"website,omitempty"` + WikiBranch string `json:"wiki_branch,omitempty"` } // RepositoryMeta — RepositoryMeta basic repository information +// +// Usage: +// +// opts := RepositoryMeta{FullName: "example"} type RepositoryMeta struct { FullName string `json:"full_name,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Owner string `json:"owner,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Owner string `json:"owner,omitempty"` } // TransferRepoOption — TransferRepoOption options when transfer a repository's ownership +// +// Usage: +// +// opts := TransferRepoOption{NewOwner: "example"} type TransferRepoOption struct { - NewOwner string `json:"new_owner"` - TeamIDs []int64 `json:"team_ids,omitempty"` // ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories. + NewOwner string `json:"new_owner"` + TeamIDs []int64 `json:"team_ids,omitempty"` // ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories. } // UpdateRepoAvatarOption — UpdateRepoAvatarUserOption options when updating the repo avatar +// +// Usage: +// +// opts := UpdateRepoAvatarOption{Image: "example"} type UpdateRepoAvatarOption struct { Image string `json:"image,omitempty"` // image must be base64 encoded } - diff --git a/types/review.go b/types/review.go index 403ffa4..2c892cc 100644 --- a/types/review.go +++ b/types/review.go @@ -2,8 +2,9 @@ package types - // ReviewStateType — ReviewStateType review state type -// ReviewStateType has no fields in the swagger spec. -type ReviewStateType struct{} - +// +// Usage: +// +// opts := ReviewStateType("example") +type ReviewStateType string diff --git a/types/settings.go b/types/settings.go index df31e57..943065b 100644 --- a/types/settings.go +++ b/types/settings.go @@ -2,38 +2,52 @@ package types - // GeneralAPISettings — GeneralAPISettings contains global api settings exposed by it +// +// Usage: +// +// opts := GeneralAPISettings{DefaultGitTreesPerPage: 1} type GeneralAPISettings struct { DefaultGitTreesPerPage int64 `json:"default_git_trees_per_page,omitempty"` - DefaultMaxBlobSize int64 `json:"default_max_blob_size,omitempty"` - DefaultPagingNum int64 `json:"default_paging_num,omitempty"` - MaxResponseItems int64 `json:"max_response_items,omitempty"` + DefaultMaxBlobSize int64 `json:"default_max_blob_size,omitempty"` + DefaultPagingNum int64 `json:"default_paging_num,omitempty"` + MaxResponseItems int64 `json:"max_response_items,omitempty"` } // GeneralAttachmentSettings — GeneralAttachmentSettings contains global Attachment settings exposed by API +// +// Usage: +// +// opts := GeneralAttachmentSettings{AllowedTypes: "example"} type GeneralAttachmentSettings struct { AllowedTypes string `json:"allowed_types,omitempty"` - Enabled bool `json:"enabled,omitempty"` - MaxFiles int64 `json:"max_files,omitempty"` - MaxSize int64 `json:"max_size,omitempty"` + Enabled bool `json:"enabled,omitempty"` + MaxFiles int64 `json:"max_files,omitempty"` + MaxSize int64 `json:"max_size,omitempty"` } // GeneralRepoSettings — GeneralRepoSettings contains global repository settings exposed by API +// +// Usage: +// +// opts := GeneralRepoSettings{ForksDisabled: true} type GeneralRepoSettings struct { - ForksDisabled bool `json:"forks_disabled,omitempty"` - HTTPGitDisabled bool `json:"http_git_disabled,omitempty"` - LFSDisabled bool `json:"lfs_disabled,omitempty"` - MigrationsDisabled bool `json:"migrations_disabled,omitempty"` - MirrorsDisabled bool `json:"mirrors_disabled,omitempty"` - StarsDisabled bool `json:"stars_disabled,omitempty"` + ForksDisabled bool `json:"forks_disabled,omitempty"` + HTTPGitDisabled bool `json:"http_git_disabled,omitempty"` + LFSDisabled bool `json:"lfs_disabled,omitempty"` + MigrationsDisabled bool `json:"migrations_disabled,omitempty"` + MirrorsDisabled bool `json:"mirrors_disabled,omitempty"` + StarsDisabled bool `json:"stars_disabled,omitempty"` TimeTrackingDisabled bool `json:"time_tracking_disabled,omitempty"` } // GeneralUISettings — GeneralUISettings contains global ui settings exposed by API +// +// Usage: +// +// opts := GeneralUISettings{AllowedReactions: []string{"example"}} type GeneralUISettings struct { AllowedReactions []string `json:"allowed_reactions,omitempty"` - CustomEmojis []string `json:"custom_emojis,omitempty"` - DefaultTheme string `json:"default_theme,omitempty"` + CustomEmojis []string `json:"custom_emojis,omitempty"` + DefaultTheme string `json:"default_theme,omitempty"` } - diff --git a/types/status.go b/types/status.go index c0e5fb9..bf945f8 100644 --- a/types/status.go +++ b/types/status.go @@ -2,23 +2,29 @@ package types - // CombinedStatus — CombinedStatus holds the combined state of several statuses for a single commit +// +// Usage: +// +// opts := CombinedStatus{CommitURL: "https://example.com"} type CombinedStatus struct { - CommitURL string `json:"commit_url,omitempty"` - Repository *Repository `json:"repository,omitempty"` - SHA string `json:"sha,omitempty"` - State *CommitStatusState `json:"state,omitempty"` - Statuses []*CommitStatus `json:"statuses,omitempty"` - TotalCount int64 `json:"total_count,omitempty"` - URL string `json:"url,omitempty"` + CommitURL string `json:"commit_url,omitempty"` + Repository *Repository `json:"repository,omitempty"` + SHA string `json:"sha,omitempty"` + State CommitStatusState `json:"state,omitempty"` + Statuses []*CommitStatus `json:"statuses,omitempty"` + TotalCount int64 `json:"total_count,omitempty"` + URL string `json:"url,omitempty"` } // CreateStatusOption — CreateStatusOption holds the information needed to create a new CommitStatus for a Commit +// +// Usage: +// +// opts := CreateStatusOption{Description: "example"} type CreateStatusOption struct { - Context string `json:"context,omitempty"` - Description string `json:"description,omitempty"` - State *CommitStatusState `json:"state,omitempty"` - TargetURL string `json:"target_url,omitempty"` + Context string `json:"context,omitempty"` + Description string `json:"description,omitempty"` + State CommitStatusState `json:"state,omitempty"` + TargetURL string `json:"target_url,omitempty"` } - diff --git a/types/tag.go b/types/tag.go index 6461691..180bde9 100644 --- a/types/tag.go +++ b/types/tag.go @@ -4,52 +4,74 @@ package types import "time" - // CreateTagOption — CreateTagOption options when creating a tag +// +// Usage: +// +// opts := CreateTagOption{TagName: "v1.0.0"} type CreateTagOption struct { Message string `json:"message,omitempty"` TagName string `json:"tag_name"` - Target string `json:"target,omitempty"` + Target string `json:"target,omitempty"` } // CreateTagProtectionOption — CreateTagProtectionOption options for creating a tag protection +// +// Usage: +// +// opts := CreateTagProtectionOption{NamePattern: "example"} type CreateTagProtectionOption struct { - NamePattern string `json:"name_pattern,omitempty"` - WhitelistTeams []string `json:"whitelist_teams,omitempty"` + NamePattern string `json:"name_pattern,omitempty"` + WhitelistTeams []string `json:"whitelist_teams,omitempty"` WhitelistUsernames []string `json:"whitelist_usernames,omitempty"` } // EditTagProtectionOption — EditTagProtectionOption options for editing a tag protection +// +// Usage: +// +// opts := EditTagProtectionOption{NamePattern: "example"} type EditTagProtectionOption struct { - NamePattern string `json:"name_pattern,omitempty"` - WhitelistTeams []string `json:"whitelist_teams,omitempty"` + NamePattern string `json:"name_pattern,omitempty"` + WhitelistTeams []string `json:"whitelist_teams,omitempty"` WhitelistUsernames []string `json:"whitelist_usernames,omitempty"` } // Tag — Tag represents a repository tag +// +// Usage: +// +// opts := Tag{Name: "example"} type Tag struct { ArchiveDownloadCount *TagArchiveDownloadCount `json:"archive_download_count,omitempty"` - Commit *CommitMeta `json:"commit,omitempty"` - ID string `json:"id,omitempty"` - Message string `json:"message,omitempty"` - Name string `json:"name,omitempty"` - TarballURL string `json:"tarball_url,omitempty"` - ZipballURL string `json:"zipball_url,omitempty"` + Commit *CommitMeta `json:"commit,omitempty"` + ID string `json:"id,omitempty"` + Message string `json:"message,omitempty"` + Name string `json:"name,omitempty"` + TarballURL string `json:"tarball_url,omitempty"` + ZipballURL string `json:"zipball_url,omitempty"` } // TagArchiveDownloadCount — TagArchiveDownloadCount counts how many times a archive was downloaded +// +// Usage: +// +// opts := TagArchiveDownloadCount{TarGz: 1} type TagArchiveDownloadCount struct { TarGz int64 `json:"tar_gz,omitempty"` - Zip int64 `json:"zip,omitempty"` + Zip int64 `json:"zip,omitempty"` } // TagProtection — TagProtection represents a tag protection +// +// Usage: +// +// opts := TagProtection{NamePattern: "example"} type TagProtection struct { - Created time.Time `json:"created_at,omitempty"` - ID int64 `json:"id,omitempty"` - NamePattern string `json:"name_pattern,omitempty"` - Updated time.Time `json:"updated_at,omitempty"` - WhitelistTeams []string `json:"whitelist_teams,omitempty"` - WhitelistUsernames []string `json:"whitelist_usernames,omitempty"` + Created time.Time `json:"created_at,omitempty"` + ID int64 `json:"id,omitempty"` + NamePattern string `json:"name_pattern,omitempty"` + Updated time.Time `json:"updated_at,omitempty"` + WhitelistTeams []string `json:"whitelist_teams,omitempty"` + WhitelistUsernames []string `json:"whitelist_usernames,omitempty"` } - diff --git a/types/team.go b/types/team.go index e671d34..6084466 100644 --- a/types/team.go +++ b/types/team.go @@ -2,39 +2,49 @@ package types - // CreateTeamOption — CreateTeamOption options for creating a team +// +// Usage: +// +// opts := CreateTeamOption{Name: "example"} type CreateTeamOption struct { - CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` - Description string `json:"description,omitempty"` - IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` - Name string `json:"name"` - Permission string `json:"permission,omitempty"` - Units []string `json:"units,omitempty"` - UnitsMap map[string]any `json:"units_map,omitempty"` + CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` + Description string `json:"description,omitempty"` + IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` + Name string `json:"name"` + Permission string `json:"permission,omitempty"` + Units []string `json:"units,omitempty"` + UnitsMap map[string]string `json:"units_map,omitempty"` } // EditTeamOption — EditTeamOption options for editing a team +// +// Usage: +// +// opts := EditTeamOption{Name: "example"} type EditTeamOption struct { - CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` - Description string `json:"description,omitempty"` - IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` - Name string `json:"name"` - Permission string `json:"permission,omitempty"` - Units []string `json:"units,omitempty"` - UnitsMap map[string]any `json:"units_map,omitempty"` + CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` + Description string `json:"description,omitempty"` + IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` + Name string `json:"name"` + Permission string `json:"permission,omitempty"` + Units []string `json:"units,omitempty"` + UnitsMap map[string]string `json:"units_map,omitempty"` } // Team — Team represents a team in an organization +// +// Usage: +// +// opts := Team{Description: "example"} type Team struct { - CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` - Description string `json:"description,omitempty"` - ID int64 `json:"id,omitempty"` - IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` - Name string `json:"name,omitempty"` - Organization *Organization `json:"organization,omitempty"` - Permission string `json:"permission,omitempty"` - Units []string `json:"units,omitempty"` - UnitsMap map[string]any `json:"units_map,omitempty"` + CanCreateOrgRepo bool `json:"can_create_org_repo,omitempty"` + Description string `json:"description,omitempty"` + ID int64 `json:"id,omitempty"` + IncludesAllRepositories bool `json:"includes_all_repositories,omitempty"` + Name string `json:"name,omitempty"` + Organization *Organization `json:"organization,omitempty"` + Permission string `json:"permission,omitempty"` + Units []string `json:"units,omitempty"` + UnitsMap map[string]string `json:"units_map,omitempty"` } - diff --git a/types/time_tracking.go b/types/time_tracking.go index 097980c..a44b0b9 100644 --- a/types/time_tracking.go +++ b/types/time_tracking.go @@ -4,26 +4,32 @@ package types import "time" - // StopWatch — StopWatch represent a running stopwatch +// +// Usage: +// +// opts := StopWatch{IssueTitle: "example"} type StopWatch struct { - Created time.Time `json:"created,omitempty"` - Duration string `json:"duration,omitempty"` - IssueIndex int64 `json:"issue_index,omitempty"` - IssueTitle string `json:"issue_title,omitempty"` - RepoName string `json:"repo_name,omitempty"` - RepoOwnerName string `json:"repo_owner_name,omitempty"` - Seconds int64 `json:"seconds,omitempty"` + Created time.Time `json:"created,omitempty"` + Duration string `json:"duration,omitempty"` + IssueIndex int64 `json:"issue_index,omitempty"` + IssueTitle string `json:"issue_title,omitempty"` + RepoName string `json:"repo_name,omitempty"` + RepoOwnerName string `json:"repo_owner_name,omitempty"` + Seconds int64 `json:"seconds,omitempty"` } // TrackedTime — TrackedTime worked time for an issue / pr +// +// Usage: +// +// opts := TrackedTime{UserName: "example"} type TrackedTime struct { - Created time.Time `json:"created,omitempty"` - ID int64 `json:"id,omitempty"` - Issue *Issue `json:"issue,omitempty"` - IssueID int64 `json:"issue_id,omitempty"` // deprecated (only for backwards compatibility) - Time int64 `json:"time,omitempty"` // Time in seconds - UserID int64 `json:"user_id,omitempty"` // deprecated (only for backwards compatibility) - UserName string `json:"user_name,omitempty"` + Created time.Time `json:"created,omitempty"` + ID int64 `json:"id,omitempty"` + Issue *Issue `json:"issue,omitempty"` + IssueID int64 `json:"issue_id,omitempty"` // deprecated (only for backwards compatibility) + Time int64 `json:"time,omitempty"` // Time in seconds + UserID int64 `json:"user_id,omitempty"` // deprecated (only for backwards compatibility) + UserName string `json:"user_name,omitempty"` } - diff --git a/types/topic.go b/types/topic.go index ed5a73b..5f212f9 100644 --- a/types/topic.go +++ b/types/topic.go @@ -4,18 +4,24 @@ package types import "time" - // TopicName — TopicName a list of repo topic names +// +// Usage: +// +// opts := TopicName{TopicNames: []string{"example"}} type TopicName struct { TopicNames []string `json:"topics,omitempty"` } // TopicResponse — TopicResponse for returning topics +// +// Usage: +// +// opts := TopicResponse{Name: "example"} type TopicResponse struct { - Created time.Time `json:"created,omitempty"` - ID int64 `json:"id,omitempty"` - Name string `json:"topic_name,omitempty"` - RepoCount int64 `json:"repo_count,omitempty"` - Updated time.Time `json:"updated,omitempty"` + Created time.Time `json:"created,omitempty"` + ID int64 `json:"id,omitempty"` + Name string `json:"topic_name,omitempty"` + RepoCount int64 `json:"repo_count,omitempty"` + Updated time.Time `json:"updated,omitempty"` } - diff --git a/types/user.go b/types/user.go index f7f1176..cb76725 100644 --- a/types/user.go +++ b/types/user.go @@ -4,139 +4,184 @@ package types import "time" - +// Usage: +// +// opts := BlockedUser{Created: time.Now()} type BlockedUser struct { - BlockID int64 `json:"block_id,omitempty"` + BlockID int64 `json:"block_id,omitempty"` Created time.Time `json:"created_at,omitempty"` } // CreateEmailOption — CreateEmailOption options when creating email addresses +// +// Usage: +// +// opts := CreateEmailOption{Emails: []string{"example"}} type CreateEmailOption struct { Emails []string `json:"emails,omitempty"` // email addresses to add } // CreateUserOption — CreateUserOption create user options +// +// Usage: +// +// opts := CreateUserOption{Email: "alice@example.com"} type CreateUserOption struct { - Created time.Time `json:"created_at,omitempty"` // For explicitly setting the user creation timestamp. Useful when users are migrated from other systems. When omitted, the user's creation timestamp will be set to "now". - Email string `json:"email"` - FullName string `json:"full_name,omitempty"` - LoginName string `json:"login_name,omitempty"` - MustChangePassword bool `json:"must_change_password,omitempty"` - Password string `json:"password,omitempty"` - Restricted bool `json:"restricted,omitempty"` - SendNotify bool `json:"send_notify,omitempty"` - SourceID int64 `json:"source_id,omitempty"` - Username string `json:"username"` - Visibility string `json:"visibility,omitempty"` + Created time.Time `json:"created_at,omitempty"` // For explicitly setting the user creation timestamp. Useful when users are migrated from other systems. When omitted, the user's creation timestamp will be set to "now". + Email string `json:"email"` + FullName string `json:"full_name,omitempty"` + LoginName string `json:"login_name,omitempty"` + MustChangePassword bool `json:"must_change_password,omitempty"` + Password string `json:"password,omitempty"` + Restricted bool `json:"restricted,omitempty"` + SendNotify bool `json:"send_notify,omitempty"` + SourceID int64 `json:"source_id,omitempty"` + Username string `json:"username"` + Visibility string `json:"visibility,omitempty"` } // DeleteEmailOption — DeleteEmailOption options when deleting email addresses +// +// Usage: +// +// opts := DeleteEmailOption{Emails: []string{"example"}} type DeleteEmailOption struct { Emails []string `json:"emails,omitempty"` // email addresses to delete } // EditUserOption — EditUserOption edit user options +// +// Usage: +// +// opts := EditUserOption{Description: "example"} type EditUserOption struct { - Active bool `json:"active,omitempty"` - Admin bool `json:"admin,omitempty"` - AllowCreateOrganization bool `json:"allow_create_organization,omitempty"` - AllowGitHook bool `json:"allow_git_hook,omitempty"` - AllowImportLocal bool `json:"allow_import_local,omitempty"` - Description string `json:"description,omitempty"` - Email string `json:"email,omitempty"` - FullName string `json:"full_name,omitempty"` - Location string `json:"location,omitempty"` - LoginName string `json:"login_name,omitempty"` - MaxRepoCreation int64 `json:"max_repo_creation,omitempty"` - MustChangePassword bool `json:"must_change_password,omitempty"` - Password string `json:"password,omitempty"` - ProhibitLogin bool `json:"prohibit_login,omitempty"` - Pronouns string `json:"pronouns,omitempty"` - Restricted bool `json:"restricted,omitempty"` - SourceID int64 `json:"source_id,omitempty"` - Visibility string `json:"visibility,omitempty"` - Website string `json:"website,omitempty"` + Active bool `json:"active,omitempty"` + Admin bool `json:"admin,omitempty"` + AllowCreateOrganization bool `json:"allow_create_organization,omitempty"` + AllowGitHook bool `json:"allow_git_hook,omitempty"` + AllowImportLocal bool `json:"allow_import_local,omitempty"` + Description string `json:"description,omitempty"` + Email string `json:"email,omitempty"` + FullName string `json:"full_name,omitempty"` + Location string `json:"location,omitempty"` + LoginName string `json:"login_name,omitempty"` + MaxRepoCreation int64 `json:"max_repo_creation,omitempty"` + MustChangePassword bool `json:"must_change_password,omitempty"` + Password string `json:"password,omitempty"` + ProhibitLogin bool `json:"prohibit_login,omitempty"` + Pronouns string `json:"pronouns,omitempty"` + Restricted bool `json:"restricted,omitempty"` + SourceID int64 `json:"source_id,omitempty"` + Visibility string `json:"visibility,omitempty"` + Website string `json:"website,omitempty"` } // Email — Email an email address belonging to a user +// +// Usage: +// +// opts := Email{UserName: "example"} type Email struct { - Email string `json:"email,omitempty"` - Primary bool `json:"primary,omitempty"` - UserID int64 `json:"user_id,omitempty"` + Email string `json:"email,omitempty"` + Primary bool `json:"primary,omitempty"` + UserID int64 `json:"user_id,omitempty"` UserName string `json:"username,omitempty"` - Verified bool `json:"verified,omitempty"` + Verified bool `json:"verified,omitempty"` } // SetUserQuotaGroupsOptions — SetUserQuotaGroupsOptions represents the quota groups of a user +// +// Usage: +// +// opts := SetUserQuotaGroupsOptions{Groups: []string{"example"}} type SetUserQuotaGroupsOptions struct { Groups []string `json:"groups"` // Quota groups the user shall have } // UpdateUserAvatarOption — UpdateUserAvatarUserOption options when updating the user avatar +// +// Usage: +// +// opts := UpdateUserAvatarOption{Image: "example"} type UpdateUserAvatarOption struct { Image string `json:"image,omitempty"` // image must be base64 encoded } // User — User represents a user +// +// Usage: +// +// opts := User{Description: "example"} type User struct { - AvatarURL string `json:"avatar_url,omitempty"` // URL to the user's avatar - Created time.Time `json:"created,omitempty"` - Description string `json:"description,omitempty"` // the user's description - Email string `json:"email,omitempty"` - Followers int64 `json:"followers_count,omitempty"` // user counts - Following int64 `json:"following_count,omitempty"` - FullName string `json:"full_name,omitempty"` // the user's full name - HTMLURL string `json:"html_url,omitempty"` // URL to the user's gitea page - ID int64 `json:"id,omitempty"` // the user's id - IsActive bool `json:"active,omitempty"` // Is user active - IsAdmin bool `json:"is_admin,omitempty"` // Is the user an administrator - Language string `json:"language,omitempty"` // User locale - LastLogin time.Time `json:"last_login,omitempty"` - Location string `json:"location,omitempty"` // the user's location - LoginName string `json:"login_name,omitempty"` // the user's authentication sign-in name. - ProhibitLogin bool `json:"prohibit_login,omitempty"` // Is user login prohibited - Pronouns string `json:"pronouns,omitempty"` // the user's pronouns - Restricted bool `json:"restricted,omitempty"` // Is user restricted - SourceID int64 `json:"source_id,omitempty"` // The ID of the user's Authentication Source - StarredRepos int64 `json:"starred_repos_count,omitempty"` - UserName string `json:"login,omitempty"` // the user's username - Visibility string `json:"visibility,omitempty"` // User visibility level option: public, limited, private - Website string `json:"website,omitempty"` // the user's website + AvatarURL string `json:"avatar_url,omitempty"` // URL to the user's avatar + Created time.Time `json:"created,omitempty"` + Description string `json:"description,omitempty"` // the user's description + Email string `json:"email,omitempty"` + Followers int64 `json:"followers_count,omitempty"` // user counts + Following int64 `json:"following_count,omitempty"` + FullName string `json:"full_name,omitempty"` // the user's full name + HTMLURL string `json:"html_url,omitempty"` // URL to the user's gitea page + ID int64 `json:"id,omitempty"` // the user's id + IsActive bool `json:"active,omitempty"` // Is user active + IsAdmin bool `json:"is_admin,omitempty"` // Is the user an administrator + Language string `json:"language,omitempty"` // User locale + LastLogin time.Time `json:"last_login,omitempty"` + Location string `json:"location,omitempty"` // the user's location + LoginName string `json:"login_name,omitempty"` // the user's authentication sign-in name. + ProhibitLogin bool `json:"prohibit_login,omitempty"` // Is user login prohibited + Pronouns string `json:"pronouns,omitempty"` // the user's pronouns + Restricted bool `json:"restricted,omitempty"` // Is user restricted + SourceID int64 `json:"source_id,omitempty"` // The ID of the user's Authentication Source + StarredRepos int64 `json:"starred_repos_count,omitempty"` + UserName string `json:"login,omitempty"` // the user's username + Visibility string `json:"visibility,omitempty"` // User visibility level option: public, limited, private + Website string `json:"website,omitempty"` // the user's website } // UserHeatmapData — UserHeatmapData represents the data needed to create a heatmap +// +// Usage: +// +// opts := UserHeatmapData{Contributions: 1} type UserHeatmapData struct { - Contributions int64 `json:"contributions,omitempty"` - Timestamp *TimeStamp `json:"timestamp,omitempty"` + Contributions int64 `json:"contributions,omitempty"` + Timestamp TimeStamp `json:"timestamp,omitempty"` } // UserSettings — UserSettings represents user settings +// +// Usage: +// +// opts := UserSettings{Description: "example"} type UserSettings struct { - Description string `json:"description,omitempty"` - DiffViewStyle string `json:"diff_view_style,omitempty"` - EnableRepoUnitHints bool `json:"enable_repo_unit_hints,omitempty"` - FullName string `json:"full_name,omitempty"` - HideActivity bool `json:"hide_activity,omitempty"` - HideEmail bool `json:"hide_email,omitempty"` // Privacy - Language string `json:"language,omitempty"` - Location string `json:"location,omitempty"` - Pronouns string `json:"pronouns,omitempty"` - Theme string `json:"theme,omitempty"` - Website string `json:"website,omitempty"` + Description string `json:"description,omitempty"` + DiffViewStyle string `json:"diff_view_style,omitempty"` + EnableRepoUnitHints bool `json:"enable_repo_unit_hints,omitempty"` + FullName string `json:"full_name,omitempty"` + HideActivity bool `json:"hide_activity,omitempty"` + HideEmail bool `json:"hide_email,omitempty"` // Privacy + Language string `json:"language,omitempty"` + Location string `json:"location,omitempty"` + Pronouns string `json:"pronouns,omitempty"` + Theme string `json:"theme,omitempty"` + Website string `json:"website,omitempty"` } // UserSettingsOptions — UserSettingsOptions represents options to change user settings +// +// Usage: +// +// opts := UserSettingsOptions{Description: "example"} type UserSettingsOptions struct { - Description string `json:"description,omitempty"` - DiffViewStyle string `json:"diff_view_style,omitempty"` - EnableRepoUnitHints bool `json:"enable_repo_unit_hints,omitempty"` - FullName string `json:"full_name,omitempty"` - HideActivity bool `json:"hide_activity,omitempty"` - HideEmail bool `json:"hide_email,omitempty"` // Privacy - Language string `json:"language,omitempty"` - Location string `json:"location,omitempty"` - Pronouns string `json:"pronouns,omitempty"` - Theme string `json:"theme,omitempty"` - Website string `json:"website,omitempty"` + Description string `json:"description,omitempty"` + DiffViewStyle string `json:"diff_view_style,omitempty"` + EnableRepoUnitHints bool `json:"enable_repo_unit_hints,omitempty"` + FullName string `json:"full_name,omitempty"` + HideActivity bool `json:"hide_activity,omitempty"` + HideEmail bool `json:"hide_email,omitempty"` // Privacy + Language string `json:"language,omitempty"` + Location string `json:"location,omitempty"` + Pronouns string `json:"pronouns,omitempty"` + Theme string `json:"theme,omitempty"` + Website string `json:"website,omitempty"` } - diff --git a/types/wiki.go b/types/wiki.go index 7c80011..a570999 100644 --- a/types/wiki.go +++ b/types/wiki.go @@ -2,45 +2,63 @@ package types - // CreateWikiPageOptions — CreateWikiPageOptions form for creating wiki +// +// Usage: +// +// opts := CreateWikiPageOptions{Title: "example"} type CreateWikiPageOptions struct { ContentBase64 string `json:"content_base64,omitempty"` // content must be base64 encoded - Message string `json:"message,omitempty"` // optional commit message summarizing the change - Title string `json:"title,omitempty"` // page title. leave empty to keep unchanged + Message string `json:"message,omitempty"` // optional commit message summarizing the change + Title string `json:"title,omitempty"` // page title. leave empty to keep unchanged } // WikiCommit — WikiCommit page commit/revision +// +// Usage: +// +// opts := WikiCommit{ID: "example"} type WikiCommit struct { - Author *CommitUser `json:"author,omitempty"` + Author *CommitUser `json:"author,omitempty"` Commiter *CommitUser `json:"commiter,omitempty"` - ID string `json:"sha,omitempty"` - Message string `json:"message,omitempty"` + ID string `json:"sha,omitempty"` + Message string `json:"message,omitempty"` } // WikiCommitList — WikiCommitList commit/revision list +// +// Usage: +// +// opts := WikiCommitList{Count: 1} type WikiCommitList struct { - Count int64 `json:"count,omitempty"` + Count int64 `json:"count,omitempty"` WikiCommits []*WikiCommit `json:"commits,omitempty"` } // WikiPage — WikiPage a wiki page +// +// Usage: +// +// opts := WikiPage{Title: "example"} type WikiPage struct { - CommitCount int64 `json:"commit_count,omitempty"` - ContentBase64 string `json:"content_base64,omitempty"` // Page content, base64 encoded - Footer string `json:"footer,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - LastCommit *WikiCommit `json:"last_commit,omitempty"` - Sidebar string `json:"sidebar,omitempty"` - SubURL string `json:"sub_url,omitempty"` - Title string `json:"title,omitempty"` + CommitCount int64 `json:"commit_count,omitempty"` + ContentBase64 string `json:"content_base64,omitempty"` // Page content, base64 encoded + Footer string `json:"footer,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + LastCommit *WikiCommit `json:"last_commit,omitempty"` + Sidebar string `json:"sidebar,omitempty"` + SubURL string `json:"sub_url,omitempty"` + Title string `json:"title,omitempty"` } // WikiPageMetaData — WikiPageMetaData wiki page meta information +// +// Usage: +// +// opts := WikiPageMetaData{Title: "example"} type WikiPageMetaData struct { - HTMLURL string `json:"html_url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` LastCommit *WikiCommit `json:"last_commit,omitempty"` - SubURL string `json:"sub_url,omitempty"` - Title string `json:"title,omitempty"` + SubURL string `json:"sub_url,omitempty"` + Title string `json:"title,omitempty"` } - diff --git a/users.go b/users.go index 2aba489..d0254a8 100644 --- a/users.go +++ b/users.go @@ -2,17 +2,82 @@ package forge import ( "context" - "fmt" "iter" + "net/http" + "net/url" + "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // UserService handles user operations. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Users.GetCurrent(ctx) type UserService struct { Resource[types.User, struct{}, struct{}] } +// UserSearchOptions controls filtering for user searches. +// +// Usage: +// +// opts := forge.UserSearchOptions{UID: 1001} +type UserSearchOptions struct { + UID int64 +} + +// String returns a safe summary of the user search filters. +func (o UserSearchOptions) String() string { + return optionString("forge.UserSearchOptions", "uid", o.UID) +} + +// GoString returns a safe Go-syntax summary of the user search filters. +func (o UserSearchOptions) GoString() string { return o.String() } + +func (o UserSearchOptions) queryParams() map[string]string { + if o.UID == 0 { + return nil + } + return map[string]string{ + "uid": strconv.FormatInt(o.UID, 10), + } +} + +// UserKeyListOptions controls filtering for authenticated user public key listings. +// +// Usage: +// +// opts := forge.UserKeyListOptions{Fingerprint: "AB:CD"} +type UserKeyListOptions struct { + Fingerprint string +} + +// String returns a safe summary of the user key filters. +func (o UserKeyListOptions) String() string { + return optionString("forge.UserKeyListOptions", "fingerprint", o.Fingerprint) +} + +// GoString returns a safe Go-syntax summary of the user key filters. +func (o UserKeyListOptions) GoString() string { return o.String() } + +func (o UserKeyListOptions) queryParams() map[string]string { + if o.Fingerprint == "" { + return nil + } + return map[string]string{ + "fingerprint": o.Fingerprint, + } +} + +type userSearchResults struct { + Data []*types.User `json:"data,omitempty"` + OK bool `json:"ok,omitempty"` +} + func newUserService(c *Client) *UserService { return &UserService{ Resource: *NewResource[types.User, struct{}, struct{}]( @@ -30,62 +95,648 @@ func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { return &out, nil } +// GetSettings returns the authenticated user's settings. +func (s *UserService) GetSettings(ctx context.Context) (*types.UserSettings, error) { + var out types.UserSettings + if err := s.client.Get(ctx, "/api/v1/user/settings", &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateSettings updates the authenticated user's settings. +func (s *UserService) UpdateSettings(ctx context.Context, opts *types.UserSettingsOptions) (*types.UserSettings, error) { + var out types.UserSettings + if err := s.client.Patch(ctx, "/api/v1/user/settings", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetQuota returns the authenticated user's quota information. +func (s *UserService) GetQuota(ctx context.Context) (*types.QuotaInfo, error) { + var out types.QuotaInfo + if err := s.client.Get(ctx, "/api/v1/user/quota", &out); err != nil { + return nil, err + } + return &out, nil +} + +// SearchUsersPage returns a single page of users matching the search filters. +func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpts ListOptions, filters ...UserSearchOptions) (*PagedResult[types.User], error) { + if pageOpts.Page < 1 { + pageOpts.Page = 1 + } + if pageOpts.Limit < 1 { + pageOpts.Limit = 50 + } + + u, err := url.Parse("/api/v1/users/search") + if err != nil { + return nil, core.E("UserService.SearchUsersPage", "forge: parse path", err) + } + + q := u.Query() + q.Set("q", query) + for _, filter := range filters { + for key, value := range filter.queryParams() { + q.Set(key, value) + } + } + q.Set("page", strconv.Itoa(pageOpts.Page)) + q.Set("limit", strconv.Itoa(pageOpts.Limit)) + u.RawQuery = q.Encode() + + var out userSearchResults + resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + items := make([]types.User, 0, len(out.Data)) + for _, user := range out.Data { + if user != nil { + items = append(items, *user) + } + } + + return &PagedResult[types.User]{ + Items: items, + TotalCount: totalCount, + Page: pageOpts.Page, + HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageOpts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageOpts.Limit), + }, nil +} + +// SearchUsers returns all users matching the search filters. +func (s *UserService) SearchUsers(ctx context.Context, query string, filters ...UserSearchOptions) ([]types.User, error) { + var all []types.User + page := 1 + + for { + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +// IterSearchUsers returns an iterator over users matching the search filters. +func (s *UserService) IterSearchUsers(ctx context.Context, query string, filters ...UserSearchOptions) iter.Seq2[types.User, error] { + return func(yield func(types.User, error) bool) { + page := 1 + for { + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + if err != nil { + yield(*new(types.User), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + +// ListQuotaArtifacts returns all artifacts affecting the authenticated user's quota. +func (s *UserService) ListQuotaArtifacts(ctx context.Context) ([]types.QuotaUsedArtifact, error) { + return ListAll[types.QuotaUsedArtifact](ctx, s.client, "/api/v1/user/quota/artifacts", nil) +} + +// IterQuotaArtifacts returns an iterator over all artifacts affecting the authenticated user's quota. +func (s *UserService) IterQuotaArtifacts(ctx context.Context) iter.Seq2[types.QuotaUsedArtifact, error] { + return ListIter[types.QuotaUsedArtifact](ctx, s.client, "/api/v1/user/quota/artifacts", nil) +} + +// ListQuotaAttachments returns all attachments affecting the authenticated user's quota. +func (s *UserService) ListQuotaAttachments(ctx context.Context) ([]types.QuotaUsedAttachment, error) { + return ListAll[types.QuotaUsedAttachment](ctx, s.client, "/api/v1/user/quota/attachments", nil) +} + +// IterQuotaAttachments returns an iterator over all attachments affecting the authenticated user's quota. +func (s *UserService) IterQuotaAttachments(ctx context.Context) iter.Seq2[types.QuotaUsedAttachment, error] { + return ListIter[types.QuotaUsedAttachment](ctx, s.client, "/api/v1/user/quota/attachments", nil) +} + +// ListQuotaPackages returns all packages affecting the authenticated user's quota. +func (s *UserService) ListQuotaPackages(ctx context.Context) ([]types.QuotaUsedPackage, error) { + return ListAll[types.QuotaUsedPackage](ctx, s.client, "/api/v1/user/quota/packages", nil) +} + +// IterQuotaPackages returns an iterator over all packages affecting the authenticated user's quota. +func (s *UserService) IterQuotaPackages(ctx context.Context) iter.Seq2[types.QuotaUsedPackage, error] { + return ListIter[types.QuotaUsedPackage](ctx, s.client, "/api/v1/user/quota/packages", nil) +} + +// ListEmails returns all email addresses for the authenticated user. +func (s *UserService) ListEmails(ctx context.Context) ([]types.Email, error) { + return ListAll[types.Email](ctx, s.client, "/api/v1/user/emails", nil) +} + +// IterEmails returns an iterator over all email addresses for the authenticated user. +func (s *UserService) IterEmails(ctx context.Context) iter.Seq2[types.Email, error] { + return ListIter[types.Email](ctx, s.client, "/api/v1/user/emails", nil) +} + +// AddEmails adds email addresses for the authenticated user. +func (s *UserService) AddEmails(ctx context.Context, emails ...string) ([]types.Email, error) { + var out []types.Email + if err := s.client.Post(ctx, "/api/v1/user/emails", types.CreateEmailOption{Emails: emails}, &out); err != nil { + return nil, err + } + return out, nil +} + +// DeleteEmails deletes email addresses for the authenticated user. +func (s *UserService) DeleteEmails(ctx context.Context, emails ...string) error { + return s.client.DeleteWithBody(ctx, "/api/v1/user/emails", types.DeleteEmailOption{Emails: emails}) +} + +// UpdateAvatar updates the authenticated user's avatar. +func (s *UserService) UpdateAvatar(ctx context.Context, opts *types.UpdateUserAvatarOption) error { + return s.client.Post(ctx, "/api/v1/user/avatar", opts, nil) +} + +// DeleteAvatar deletes the authenticated user's avatar. +func (s *UserService) DeleteAvatar(ctx context.Context) error { + return s.client.Delete(ctx, "/api/v1/user/avatar") +} + +// ListKeys returns all public keys owned by the authenticated user. +func (s *UserService) ListKeys(ctx context.Context, filters ...UserKeyListOptions) ([]types.PublicKey, error) { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListAll[types.PublicKey](ctx, s.client, "/api/v1/user/keys", query) +} + +// IterKeys returns an iterator over all public keys owned by the authenticated user. +func (s *UserService) IterKeys(ctx context.Context, filters ...UserKeyListOptions) iter.Seq2[types.PublicKey, error] { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListIter[types.PublicKey](ctx, s.client, "/api/v1/user/keys", query) +} + +// CreateKey creates a public key for the authenticated user. +func (s *UserService) CreateKey(ctx context.Context, opts *types.CreateKeyOption) (*types.PublicKey, error) { + var out types.PublicKey + if err := s.client.Post(ctx, "/api/v1/user/keys", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetKey returns a single public key owned by the authenticated user. +func (s *UserService) GetKey(ctx context.Context, id int64) (*types.PublicKey, error) { + path := ResolvePath("/api/v1/user/keys/{id}", pathParams("id", int64String(id))) + var out types.PublicKey + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteKey removes a public key owned by the authenticated user. +func (s *UserService) DeleteKey(ctx context.Context, id int64) error { + path := ResolvePath("/api/v1/user/keys/{id}", pathParams("id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListUserKeys returns all public keys for a user. +func (s *UserService) ListUserKeys(ctx context.Context, username string, filters ...UserKeyListOptions) ([]types.PublicKey, error) { + path := ResolvePath("/api/v1/users/{username}/keys", pathParams("username", username)) + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListAll[types.PublicKey](ctx, s.client, path, query) +} + +// IterUserKeys returns an iterator over all public keys for a user. +func (s *UserService) IterUserKeys(ctx context.Context, username string, filters ...UserKeyListOptions) iter.Seq2[types.PublicKey, error] { + path := ResolvePath("/api/v1/users/{username}/keys", pathParams("username", username)) + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListIter[types.PublicKey](ctx, s.client, path, query) +} + +// ListGPGKeys returns all GPG keys owned by the authenticated user. +func (s *UserService) ListGPGKeys(ctx context.Context) ([]types.GPGKey, error) { + return ListAll[types.GPGKey](ctx, s.client, "/api/v1/user/gpg_keys", nil) +} + +// IterGPGKeys returns an iterator over all GPG keys owned by the authenticated user. +func (s *UserService) IterGPGKeys(ctx context.Context) iter.Seq2[types.GPGKey, error] { + return ListIter[types.GPGKey](ctx, s.client, "/api/v1/user/gpg_keys", nil) +} + +// CreateGPGKey adds a GPG key for the authenticated user. +func (s *UserService) CreateGPGKey(ctx context.Context, opts *types.CreateGPGKeyOption) (*types.GPGKey, error) { + var out types.GPGKey + if err := s.client.Post(ctx, "/api/v1/user/gpg_keys", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetGPGKey returns a single GPG key owned by the authenticated user. +func (s *UserService) GetGPGKey(ctx context.Context, id int64) (*types.GPGKey, error) { + path := ResolvePath("/api/v1/user/gpg_keys/{id}", pathParams("id", int64String(id))) + var out types.GPGKey + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteGPGKey removes a GPG key owned by the authenticated user. +func (s *UserService) DeleteGPGKey(ctx context.Context, id int64) error { + path := ResolvePath("/api/v1/user/gpg_keys/{id}", pathParams("id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListUserGPGKeys returns all GPG keys for a user. +func (s *UserService) ListUserGPGKeys(ctx context.Context, username string) ([]types.GPGKey, error) { + path := ResolvePath("/api/v1/users/{username}/gpg_keys", pathParams("username", username)) + return ListAll[types.GPGKey](ctx, s.client, path, nil) +} + +// IterUserGPGKeys returns an iterator over all GPG keys for a user. +func (s *UserService) IterUserGPGKeys(ctx context.Context, username string) iter.Seq2[types.GPGKey, error] { + path := ResolvePath("/api/v1/users/{username}/gpg_keys", pathParams("username", username)) + return ListIter[types.GPGKey](ctx, s.client, path, nil) +} + +// GetGPGKeyVerificationToken returns the token used to verify a GPG key. +func (s *UserService) GetGPGKeyVerificationToken(ctx context.Context) (string, error) { + data, err := s.client.GetRaw(ctx, "/api/v1/user/gpg_key_token") + if err != nil { + return "", err + } + return string(data), nil +} + +// VerifyGPGKey verifies a GPG key for the authenticated user. +func (s *UserService) VerifyGPGKey(ctx context.Context) (*types.GPGKey, error) { + var out types.GPGKey + if err := s.client.Post(ctx, "/api/v1/user/gpg_key_verify", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTokens returns all access tokens for a user. +func (s *UserService) ListTokens(ctx context.Context, username string) ([]types.AccessToken, error) { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + return ListAll[types.AccessToken](ctx, s.client, path, nil) +} + +// IterTokens returns an iterator over all access tokens for a user. +func (s *UserService) IterTokens(ctx context.Context, username string) iter.Seq2[types.AccessToken, error] { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + return ListIter[types.AccessToken](ctx, s.client, path, nil) +} + +// CreateToken creates an access token for a user. +func (s *UserService) CreateToken(ctx context.Context, username string, opts *types.CreateAccessTokenOption) (*types.AccessToken, error) { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + var out types.AccessToken + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteToken deletes an access token for a user. +func (s *UserService) DeleteToken(ctx context.Context, username, token string) error { + path := ResolvePath("/api/v1/users/{username}/tokens/{token}", pathParams("username", username, "token", token)) + return s.client.Delete(ctx, path) +} + +// ListOAuth2Applications returns all OAuth2 applications owned by the authenticated user. +func (s *UserService) ListOAuth2Applications(ctx context.Context) ([]types.OAuth2Application, error) { + return ListAll[types.OAuth2Application](ctx, s.client, "/api/v1/user/applications/oauth2", nil) +} + +// IterOAuth2Applications returns an iterator over all OAuth2 applications owned by the authenticated user. +func (s *UserService) IterOAuth2Applications(ctx context.Context) iter.Seq2[types.OAuth2Application, error] { + return ListIter[types.OAuth2Application](ctx, s.client, "/api/v1/user/applications/oauth2", nil) +} + +// CreateOAuth2Application creates a new OAuth2 application for the authenticated user. +func (s *UserService) CreateOAuth2Application(ctx context.Context, opts *types.CreateOAuth2ApplicationOptions) (*types.OAuth2Application, error) { + var out types.OAuth2Application + if err := s.client.Post(ctx, "/api/v1/user/applications/oauth2", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetOAuth2Application returns a single OAuth2 application owned by the authenticated user. +func (s *UserService) GetOAuth2Application(ctx context.Context, id int64) (*types.OAuth2Application, error) { + path := ResolvePath("/api/v1/user/applications/oauth2/{id}", pathParams("id", int64String(id))) + var out types.OAuth2Application + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdateOAuth2Application updates an OAuth2 application owned by the authenticated user. +func (s *UserService) UpdateOAuth2Application(ctx context.Context, id int64, opts *types.CreateOAuth2ApplicationOptions) (*types.OAuth2Application, error) { + path := ResolvePath("/api/v1/user/applications/oauth2/{id}", pathParams("id", int64String(id))) + var out types.OAuth2Application + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteOAuth2Application deletes an OAuth2 application owned by the authenticated user. +func (s *UserService) DeleteOAuth2Application(ctx context.Context, id int64) error { + path := ResolvePath("/api/v1/user/applications/oauth2/{id}", pathParams("id", int64String(id))) + return s.client.Delete(ctx, path) +} + +// ListStopwatches returns all existing stopwatches for the authenticated user. +func (s *UserService) ListStopwatches(ctx context.Context) ([]types.StopWatch, error) { + return ListAll[types.StopWatch](ctx, s.client, "/api/v1/user/stopwatches", nil) +} + +// IterStopwatches returns an iterator over all existing stopwatches for the authenticated user. +func (s *UserService) IterStopwatches(ctx context.Context) iter.Seq2[types.StopWatch, error] { + return ListIter[types.StopWatch](ctx, s.client, "/api/v1/user/stopwatches", nil) +} + +// ListBlockedUsers returns all users blocked by the authenticated user. +func (s *UserService) ListBlockedUsers(ctx context.Context) ([]types.BlockedUser, error) { + return ListAll[types.BlockedUser](ctx, s.client, "/api/v1/user/list_blocked", nil) +} + +// IterBlockedUsers returns an iterator over all users blocked by the authenticated user. +func (s *UserService) IterBlockedUsers(ctx context.Context) iter.Seq2[types.BlockedUser, error] { + return ListIter[types.BlockedUser](ctx, s.client, "/api/v1/user/list_blocked", nil) +} + +// Block blocks a user as the authenticated user. +func (s *UserService) Block(ctx context.Context, username string) error { + path := ResolvePath("/api/v1/user/block/{username}", pathParams("username", username)) + return s.client.Put(ctx, path, nil, nil) +} + +// Unblock unblocks a user as the authenticated user. +func (s *UserService) Unblock(ctx context.Context, username string) error { + path := ResolvePath("/api/v1/user/unblock/{username}", pathParams("username", username)) + return s.client.Put(ctx, path, nil, nil) +} + +// ListMySubscriptions returns all repositories watched by the authenticated user. +func (s *UserService) ListMySubscriptions(ctx context.Context) ([]types.Repository, error) { + return ListAll[types.Repository](ctx, s.client, "/api/v1/user/subscriptions", nil) +} + +// IterMySubscriptions returns an iterator over all repositories watched by the authenticated user. +func (s *UserService) IterMySubscriptions(ctx context.Context) iter.Seq2[types.Repository, error] { + return ListIter[types.Repository](ctx, s.client, "/api/v1/user/subscriptions", nil) +} + +// ListMyStarred returns all repositories starred by the authenticated user. +func (s *UserService) ListMyStarred(ctx context.Context) ([]types.Repository, error) { + return ListAll[types.Repository](ctx, s.client, "/api/v1/user/starred", nil) +} + +// IterMyStarred returns an iterator over all repositories starred by the authenticated user. +func (s *UserService) IterMyStarred(ctx context.Context) iter.Seq2[types.Repository, error] { + return ListIter[types.Repository](ctx, s.client, "/api/v1/user/starred", nil) +} + +// ListMyFollowers returns all followers of the authenticated user. +func (s *UserService) ListMyFollowers(ctx context.Context) ([]types.User, error) { + return ListAll[types.User](ctx, s.client, "/api/v1/user/followers", nil) +} + +// IterMyFollowers returns an iterator over all followers of the authenticated user. +func (s *UserService) IterMyFollowers(ctx context.Context) iter.Seq2[types.User, error] { + return ListIter[types.User](ctx, s.client, "/api/v1/user/followers", nil) +} + +// ListMyFollowing returns all users followed by the authenticated user. +func (s *UserService) ListMyFollowing(ctx context.Context) ([]types.User, error) { + return ListAll[types.User](ctx, s.client, "/api/v1/user/following", nil) +} + +// IterMyFollowing returns an iterator over all users followed by the authenticated user. +func (s *UserService) IterMyFollowing(ctx context.Context) iter.Seq2[types.User, error] { + return ListIter[types.User](ctx, s.client, "/api/v1/user/following", nil) +} + +// ListMyTeams returns all teams the authenticated user belongs to. +func (s *UserService) ListMyTeams(ctx context.Context) ([]types.Team, error) { + return ListAll[types.Team](ctx, s.client, "/api/v1/user/teams", nil) +} + +// IterMyTeams returns an iterator over all teams the authenticated user belongs to. +func (s *UserService) IterMyTeams(ctx context.Context) iter.Seq2[types.Team, error] { + return ListIter[types.Team](ctx, s.client, "/api/v1/user/teams", nil) +} + +// ListMyTrackedTimes returns all tracked times logged by the authenticated user. +func (s *UserService) ListMyTrackedTimes(ctx context.Context) ([]types.TrackedTime, error) { + return ListAll[types.TrackedTime](ctx, s.client, "/api/v1/user/times", nil) +} + +// IterMyTrackedTimes returns an iterator over all tracked times logged by the authenticated user. +func (s *UserService) IterMyTrackedTimes(ctx context.Context) iter.Seq2[types.TrackedTime, error] { + return ListIter[types.TrackedTime](ctx, s.client, "/api/v1/user/times", nil) +} + +// CheckQuota reports whether the authenticated user is over quota. +func (s *UserService) CheckQuota(ctx context.Context) (bool, error) { + var out bool + if err := s.client.Get(ctx, "/api/v1/user/quota/check", &out); err != nil { + return false, err + } + return out, nil +} + +// GetRunnerRegistrationToken returns the authenticated user's actions runner registration token. +func (s *UserService) GetRunnerRegistrationToken(ctx context.Context) (string, error) { + path := "/api/v1/user/actions/runners/registration-token" + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return "", err + } + return resp.Header.Get("token"), nil +} + // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/users/%s/followers", username) + path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) return ListAll[types.User](ctx, s.client, path, nil) } // IterFollowers returns an iterator over all followers of a user. func (s *UserService) IterFollowers(ctx context.Context, username string) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/users/%s/followers", username) + path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) return ListIter[types.User](ctx, s.client, path, nil) } +// ListSubscriptions returns all repositories watched by a user. +func (s *UserService) ListSubscriptions(ctx context.Context, username string) ([]types.Repository, error) { + path := ResolvePath("/api/v1/users/{username}/subscriptions", pathParams("username", username)) + return ListAll[types.Repository](ctx, s.client, path, nil) +} + +// IterSubscriptions returns an iterator over all repositories watched by a user. +func (s *UserService) IterSubscriptions(ctx context.Context, username string) iter.Seq2[types.Repository, error] { + path := ResolvePath("/api/v1/users/{username}/subscriptions", pathParams("username", username)) + return ListIter[types.Repository](ctx, s.client, path, nil) +} + // ListFollowing returns all users that a user is following. func (s *UserService) ListFollowing(ctx context.Context, username string) ([]types.User, error) { - path := fmt.Sprintf("/api/v1/users/%s/following", username) + path := ResolvePath("/api/v1/users/{username}/following", pathParams("username", username)) return ListAll[types.User](ctx, s.client, path, nil) } // IterFollowing returns an iterator over all users that a user is following. func (s *UserService) IterFollowing(ctx context.Context, username string) iter.Seq2[types.User, error] { - path := fmt.Sprintf("/api/v1/users/%s/following", username) + path := ResolvePath("/api/v1/users/{username}/following", pathParams("username", username)) return ListIter[types.User](ctx, s.client, path, nil) } +// ListActivityFeeds returns a user's activity feed entries. +func (s *UserService) ListActivityFeeds(ctx context.Context, username string) ([]types.Activity, error) { + path := ResolvePath("/api/v1/users/{username}/activities/feeds", pathParams("username", username)) + return ListAll[types.Activity](ctx, s.client, path, nil) +} + +// IterActivityFeeds returns an iterator over a user's activity feed entries. +func (s *UserService) IterActivityFeeds(ctx context.Context, username string) iter.Seq2[types.Activity, error] { + path := ResolvePath("/api/v1/users/{username}/activities/feeds", pathParams("username", username)) + return ListIter[types.Activity](ctx, s.client, path, nil) +} + +// ListRepos returns all repositories owned by a user. +func (s *UserService) ListRepos(ctx context.Context, username string) ([]types.Repository, error) { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username)) + return ListAll[types.Repository](ctx, s.client, path, nil) +} + +// IterRepos returns an iterator over all repositories owned by a user. +func (s *UserService) IterRepos(ctx context.Context, username string) iter.Seq2[types.Repository, error] { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username)) + return ListIter[types.Repository](ctx, s.client, path, nil) +} + // Follow follows a user as the authenticated user. func (s *UserService) Follow(ctx context.Context, username string) error { - path := fmt.Sprintf("/api/v1/user/following/%s", username) + path := ResolvePath("/api/v1/user/following/{username}", pathParams("username", username)) return s.client.Put(ctx, path, nil, nil) } +// CheckFollowing reports whether one user is following another user. +func (s *UserService) CheckFollowing(ctx context.Context, username, target string) (bool, error) { + path := ResolvePath("/api/v1/users/{username}/following/{target}", pathParams("username", username, "target", target)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} + // Unfollow unfollows a user as the authenticated user. func (s *UserService) Unfollow(ctx context.Context, username string) error { - path := fmt.Sprintf("/api/v1/user/following/%s", username) + path := ResolvePath("/api/v1/user/following/{username}", pathParams("username", username)) return s.client.Delete(ctx, path) } // ListStarred returns all repositories starred by a user. func (s *UserService) ListStarred(ctx context.Context, username string) ([]types.Repository, error) { - path := fmt.Sprintf("/api/v1/users/%s/starred", username) + path := ResolvePath("/api/v1/users/{username}/starred", pathParams("username", username)) return ListAll[types.Repository](ctx, s.client, path, nil) } // IterStarred returns an iterator over all repositories starred by a user. func (s *UserService) IterStarred(ctx context.Context, username string) iter.Seq2[types.Repository, error] { - path := fmt.Sprintf("/api/v1/users/%s/starred", username) + path := ResolvePath("/api/v1/users/{username}/starred", pathParams("username", username)) return ListIter[types.Repository](ctx, s.client, path, nil) } +// GetHeatmap returns a user's contribution heatmap data. +func (s *UserService) GetHeatmap(ctx context.Context, username string) ([]types.UserHeatmapData, error) { + path := ResolvePath("/api/v1/users/{username}/heatmap", pathParams("username", username)) + var out []types.UserHeatmapData + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return out, nil +} + // Star stars a repository as the authenticated user. func (s *UserService) Star(ctx context.Context, owner, repo string) error { - path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + path := ResolvePath("/api/v1/user/starred/{owner}/{repo}", pathParams("owner", owner, "repo", repo)) return s.client.Put(ctx, path, nil, nil) } // Unstar unstars a repository as the authenticated user. func (s *UserService) Unstar(ctx context.Context, owner, repo string) error { - path := fmt.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) + path := ResolvePath("/api/v1/user/starred/{owner}/{repo}", pathParams("owner", owner, "repo", repo)) return s.client.Delete(ctx, path) } + +// CheckStarring reports whether the authenticated user is starring a repository. +func (s *UserService) CheckStarring(ctx context.Context, owner, repo string) (bool, error) { + path := ResolvePath("/api/v1/user/starred/{owner}/{repo}", pathParams("owner", owner, "repo", repo)) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, nil) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusNoContent, nil +} diff --git a/users_extra_test.go b/users_extra_test.go new file mode 100644 index 0000000..bcd80a3 --- /dev/null +++ b/users_extra_test.go @@ -0,0 +1,34 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestUserService_ListMyFollowing_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/following" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.User{{ID: 1, UserName: "alice"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + users, err := f.Users.ListMyFollowing(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(users) != 1 || users[0].UserName != "alice" { + t.Fatalf("got %#v", users) + } +} diff --git a/users_test.go b/users_test.go index 015f7f8..9fe78e2 100644 --- a/users_test.go +++ b/users_test.go @@ -2,15 +2,16 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "strconv" "testing" "dappco.re/go/core/forge/types" ) -func TestUserService_Good_Get(t *testing.T) { +func TestUserService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -32,7 +33,7 @@ func TestUserService_Good_Get(t *testing.T) { } } -func TestUserService_Good_GetCurrent(t *testing.T) { +func TestUserService_GetCurrent_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -54,7 +55,1423 @@ func TestUserService_Good_GetCurrent(t *testing.T) { } } -func TestUserService_Good_ListFollowers(t *testing.T) { +func TestUserService_GetSettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/settings" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.UserSettings{ + FullName: "Alice", + Language: "en-US", + Theme: "forgejo-auto", + HideEmail: true, + Pronouns: "she/her", + Website: "https://example.com", + Location: "Earth", + Description: "maintainer", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Users.GetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.FullName != "Alice" { + t.Errorf("got full name=%q, want %q", settings.FullName, "Alice") + } + if !settings.HideEmail { + t.Errorf("got hide_email=%v, want true", settings.HideEmail) + } +} + +func TestUserService_UpdateSettings_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/settings" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.UserSettingsOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.FullName != "Alice" || !body.HideEmail || body.Theme != "forgejo-auto" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.UserSettings{ + FullName: body.FullName, + HideEmail: body.HideEmail, + Theme: body.Theme, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + settings, err := f.Users.UpdateSettings(context.Background(), &types.UserSettingsOptions{ + FullName: "Alice", + HideEmail: true, + Theme: "forgejo-auto", + }) + if err != nil { + t.Fatal(err) + } + if settings.FullName != "Alice" { + t.Errorf("got full name=%q, want %q", settings.FullName, "Alice") + } + if !settings.HideEmail { + t.Errorf("got hide_email=%v, want true", settings.HideEmail) + } +} + +func TestUserService_GetQuota_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.QuotaInfo{ + Groups: types.QuotaGroupList{}, + Used: &types.QuotaUsed{ + Size: &types.QuotaUsedSize{ + Repos: &types.QuotaUsedSizeRepos{ + Public: 123, + Private: 456, + }, + }, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + quota, err := f.Users.GetQuota(context.Background()) + if err != nil { + t.Fatal(err) + } + if quota.Used == nil || quota.Used.Size == nil || quota.Used.Size.Repos == nil { + t.Fatalf("quota usage was not decoded: %+v", quota) + } + if quota.Used.Size.Repos.Public != 123 || quota.Used.Size.Repos.Private != 456 { + t.Errorf("unexpected repository quota usage: %+v", quota.Used.Size.Repos) + } +} + +func TestUserService_SearchUsersPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "al" { + t.Errorf("wrong q: %s", got) + } + if got := r.URL.Query().Get("uid"); got != "7" { + t.Errorf("wrong uid: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "alex"}, + }, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Users.SearchUsersPage(context.Background(), "al", ListOptions{}, UserSearchOptions{UID: 7}) + if err != nil { + t.Fatal(err) + } + if result.TotalCount != 2 || result.Page != 1 || result.HasMore { + t.Fatalf("got %#v", result) + } + if len(result.Items) != 2 || result.Items[0].UserName != "alice" || result.Items[1].UserName != "alex" { + t.Fatalf("got %#v", result.Items) + } +} + +func TestUserService_SearchUsers_Good(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "al" { + t.Errorf("wrong q: %s", got) + } + if got := r.URL.Query().Get("limit"); got != strconv.Itoa(50) { + t.Errorf("wrong limit: %s", got) + } + + switch r.URL.Query().Get("page") { + case "1": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 1, UserName: "alice"}, + {ID: 2, UserName: "alex"}, + }, + "ok": true, + }) + case "2": + w.Header().Set("X-Total-Count", "3") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 3, UserName: "ally"}, + }, + "ok": true, + }) + default: + t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) + } + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for user, err := range f.Users.IterSearchUsers(context.Background(), "al") { + if err != nil { + t.Fatal(err) + } + got = append(got, user.UserName) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } + if len(got) != 3 || got[0] != "alice" || got[1] != "alex" || got[2] != "ally" { + t.Fatalf("got %#v", got) + } +} + +func TestUserService_ListQuotaArtifacts_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/artifacts" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.QuotaUsedArtifact{ + {Name: "artifact-1", Size: 123, HTMLURL: "https://example.com/actions/runs/1"}, + {Name: "artifact-2", Size: 456, HTMLURL: "https://example.com/actions/runs/2"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + artifacts, err := f.Users.ListQuotaArtifacts(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(artifacts) != 2 { + t.Fatalf("got %d artifacts, want 2", len(artifacts)) + } + if artifacts[0].Name != "artifact-1" || artifacts[0].Size != 123 { + t.Errorf("unexpected first artifact: %+v", artifacts[0]) + } +} + +func TestUserService_IterQuotaArtifacts_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/artifacts" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.QuotaUsedArtifact{ + {Name: "artifact-1", Size: 123, HTMLURL: "https://example.com/actions/runs/1"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.QuotaUsedArtifact + for artifact, err := range f.Users.IterQuotaArtifacts(context.Background()) { + if err != nil { + t.Fatal(err) + } + got = append(got, artifact) + } + if len(got) != 1 { + t.Fatalf("got %d artifacts, want 1", len(got)) + } + if got[0].Name != "artifact-1" { + t.Errorf("unexpected artifact: %+v", got[0]) + } +} + +func TestUserService_ListQuotaAttachments_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/attachments" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.QuotaUsedAttachment{ + {Name: "issue-attachment.png", Size: 123, APIURL: "https://example.com/api/attachments/1"}, + {Name: "release-attachment.tar.gz", Size: 456, APIURL: "https://example.com/api/attachments/2"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + attachments, err := f.Users.ListQuotaAttachments(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(attachments) != 2 { + t.Fatalf("got %d attachments, want 2", len(attachments)) + } + if attachments[0].Name != "issue-attachment.png" || attachments[0].Size != 123 { + t.Errorf("unexpected first attachment: %+v", attachments[0]) + } +} + +func TestUserService_IterQuotaAttachments_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/attachments" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.QuotaUsedAttachment{ + {Name: "issue-attachment.png", Size: 123, APIURL: "https://example.com/api/attachments/1"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.QuotaUsedAttachment + for attachment, err := range f.Users.IterQuotaAttachments(context.Background()) { + if err != nil { + t.Fatal(err) + } + got = append(got, attachment) + } + if len(got) != 1 { + t.Fatalf("got %d attachments, want 1", len(got)) + } + if got[0].Name != "issue-attachment.png" { + t.Errorf("unexpected attachment: %+v", got[0]) + } +} + +func TestUserService_ListQuotaPackages_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/packages" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.QuotaUsedPackage{ + {Name: "pkg-one", Type: "container", Version: "1.0.0", Size: 123, HTMLURL: "https://example.com/packages/1"}, + {Name: "pkg-two", Type: "npm", Version: "2.0.0", Size: 456, HTMLURL: "https://example.com/packages/2"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + packages, err := f.Users.ListQuotaPackages(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(packages) != 2 { + t.Fatalf("got %d packages, want 2", len(packages)) + } + if packages[0].Name != "pkg-one" || packages[0].Type != "container" || packages[0].Size != 123 { + t.Errorf("unexpected first package: %+v", packages[0]) + } +} + +func TestUserService_IterQuotaPackages_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/quota/packages" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.QuotaUsedPackage{ + {Name: "pkg-one", Type: "container", Version: "1.0.0", Size: 123, HTMLURL: "https://example.com/packages/1"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []types.QuotaUsedPackage + for pkg, err := range f.Users.IterQuotaPackages(context.Background()) { + if err != nil { + t.Fatal(err) + } + got = append(got, pkg) + } + if len(got) != 1 { + t.Fatalf("got %d packages, want 1", len(got)) + } + if got[0].Name != "pkg-one" || got[0].Type != "container" { + t.Errorf("unexpected package: %+v", got[0]) + } +} + +func TestUserService_ListEmails_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/emails" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Email{ + {Email: "alice@example.com", Primary: true}, + {Email: "alice+alt@example.com", Verified: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + emails, err := f.Users.ListEmails(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(emails) != 2 { + t.Fatalf("got %d emails, want 2", len(emails)) + } + if emails[0].Email != "alice@example.com" || !emails[0].Primary { + t.Errorf("unexpected first email: %+v", emails[0]) + } +} + +func TestUserService_ListStopwatches_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/stopwatches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.StopWatch{ + {IssueIndex: 12, IssueTitle: "First issue", RepoOwnerName: "core", RepoName: "go-forge", Seconds: 30}, + {IssueIndex: 13, IssueTitle: "Second issue", RepoOwnerName: "core", RepoName: "go-forge", Seconds: 90}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + stopwatches, err := f.Users.ListStopwatches(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(stopwatches) != 2 { + t.Fatalf("got %d stopwatches, want 2", len(stopwatches)) + } + if stopwatches[0].IssueIndex != 12 || stopwatches[0].Seconds != 30 { + t.Errorf("unexpected first stopwatch: %+v", stopwatches[0]) + } +} + +func TestUserService_ListBlockedUsers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/list_blocked" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.BlockedUser{ + {BlockID: 11}, + {BlockID: 12}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + blocked, err := f.Users.ListBlockedUsers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(blocked) != 2 { + t.Fatalf("got %d blocked users, want 2", len(blocked)) + } + if blocked[0].BlockID != 11 { + t.Errorf("unexpected first blocked user: %+v", blocked[0]) + } +} + +func TestUserService_Block_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/block/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.Block(context.Background(), "alice"); err != nil { + t.Fatal(err) + } +} + +func TestUserService_CheckFollowing_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/following/bob" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + following, err := f.Users.CheckFollowing(context.Background(), "alice", "bob") + if err != nil { + t.Fatal(err) + } + if !following { + t.Fatal("got following=false, want true") + } +} + +func TestUserService_CheckFollowing_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/following/bob" { + t.Errorf("wrong path: %s", r.URL.Path) + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + following, err := f.Users.CheckFollowing(context.Background(), "alice", "bob") + if err != nil { + t.Fatal(err) + } + if following { + t.Fatal("got following=true, want false") + } +} + +func TestUserService_Unblock_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/unblock/alice" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.Unblock(context.Background(), "alice"); err != nil { + t.Fatal(err) + } +} + +func TestUserService_IterBlockedUsers_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/list_blocked" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.BlockedUser{ + {BlockID: 77}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for blocked, err := range f.Users.IterBlockedUsers(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if blocked.BlockID != 77 { + t.Errorf("unexpected blocked user: %+v", blocked) + } + } + if count != 1 { + t.Fatalf("got %d blocked users, want 1", count) + } +} + +func TestUserService_ListMySubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Users.ListMySubscriptions(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 { + t.Fatalf("got %d repositories, want 1", len(repos)) + } + if repos[0].FullName != "core/go-forge" { + t.Errorf("got full name=%q, want %q", repos[0].FullName, "core/go-forge") + } +} + +func TestUserService_IterMySubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for repo, err := range f.Users.IterMySubscriptions(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if repo.FullName != "core/go-forge" { + t.Errorf("got full name=%q, want %q", repo.FullName, "core/go-forge") + } + } + if count != 1 { + t.Fatalf("got %d repositories, want 1", count) + } +} + +func TestUserService_IterStopwatches_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/stopwatches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.StopWatch{ + {IssueIndex: 99, IssueTitle: "Running task", RepoOwnerName: "core", RepoName: "go-forge", Seconds: 300}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for sw, err := range f.Users.IterStopwatches(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if sw.IssueIndex != 99 || sw.Seconds != 300 { + t.Errorf("unexpected stopwatch: %+v", sw) + } + } + if count != 1 { + t.Fatalf("got %d stopwatches, want 1", count) + } +} + +func TestUserService_AddEmails_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/emails" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateEmailOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Emails) != 2 || body.Emails[0] != "alice@example.com" || body.Emails[1] != "alice+alt@example.com" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode([]types.Email{ + {Email: "alice@example.com", Primary: true}, + {Email: "alice+alt@example.com", Verified: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + emails, err := f.Users.AddEmails(context.Background(), "alice@example.com", "alice+alt@example.com") + if err != nil { + t.Fatal(err) + } + if len(emails) != 2 { + t.Fatalf("got %d emails, want 2", len(emails)) + } + if emails[1].Email != "alice+alt@example.com" || !emails[1].Verified { + t.Errorf("unexpected second email: %+v", emails[1]) + } +} + +func TestUserService_DeleteEmails_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/emails" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.DeleteEmailOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Emails) != 1 || body.Emails[0] != "alice+alt@example.com" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteEmails(context.Background(), "alice+alt@example.com"); err != nil { + t.Fatal(err) + } +} + +func TestUserService_UpdateAvatar_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/avatar" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.UpdateUserAvatarOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Image != "aGVsbG8=" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.UpdateAvatar(context.Background(), &types.UpdateUserAvatarOption{Image: "aGVsbG8="}); err != nil { + t.Fatal(err) + } +} + +func TestUserService_DeleteAvatar_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/avatar" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteAvatar(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestUserService_ListKeysWithFilters_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("fingerprint"); got != "ABCD1234" { + t.Fatalf("unexpected fingerprint query: %q", got) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PublicKey{ + {ID: 1, Title: "laptop", ReadOnly: true}, + {ID: 2, Title: "desktop", ReadOnly: false}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListKeys(context.Background(), UserKeyListOptions{ + Fingerprint: "ABCD1234", + }) + if err != nil { + t.Fatal(err) + } + if len(keys) != 2 { + t.Fatalf("got %d keys, want 2", len(keys)) + } + if keys[0].ID != 1 || keys[0].Title != "laptop" || !keys[0].ReadOnly { + t.Errorf("unexpected first key: %+v", keys[0]) + } +} + +func TestUserService_IterKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PublicKey{ + {ID: 3, Title: "workstation", KeyType: "ssh-ed25519"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for key, err := range f.Users.IterKeys(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if key.ID != 3 || key.Title != "workstation" || key.KeyType != "ssh-ed25519" { + t.Errorf("unexpected key: %+v", key) + } + } + if count != 1 { + t.Fatalf("got %d keys, want 1", count) + } +} + +func TestUserService_CreateKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateKeyOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Key != "ssh-ed25519 AAAAC3Nza..." || body.Title != "laptop" || !body.ReadOnly { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.PublicKey{ + ID: 9, + Title: "laptop", + KeyType: "ssh-ed25519", + ReadOnly: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.CreateKey(context.Background(), &types.CreateKeyOption{ + Key: "ssh-ed25519 AAAAC3Nza...", + Title: "laptop", + ReadOnly: true, + }) + if err != nil { + t.Fatal(err) + } + if key.ID != 9 || key.Title != "laptop" || key.KeyType != "ssh-ed25519" || !key.ReadOnly { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_GetKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/keys/9" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.PublicKey{ + ID: 9, + Title: "laptop", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.GetKey(context.Background(), 9) + if err != nil { + t.Fatal(err) + } + if key.ID != 9 || key.Title != "laptop" { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_DeleteKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/keys/9" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteKey(context.Background(), 9); err != nil { + t.Fatal(err) + } +} + +func TestUserService_ListGPGKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.GPGKey{ + {ID: 1, KeyID: "ABCD1234", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----"}, + {ID: 2, KeyID: "EFGH5678", Verified: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListGPGKeys(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(keys) != 2 { + t.Fatalf("got %d keys, want 2", len(keys)) + } + if keys[0].ID != 1 || keys[0].KeyID != "ABCD1234" { + t.Errorf("unexpected first key: %+v", keys[0]) + } +} + +func TestUserService_IterGPGKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.GPGKey{ + {ID: 3, KeyID: "IJKL9012", Verified: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for key, err := range f.Users.IterGPGKeys(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if key.ID != 3 || key.KeyID != "IJKL9012" { + t.Errorf("unexpected key: %+v", key) + } + } + if count != 1 { + t.Fatalf("got %d keys, want 1", count) + } +} + +func TestUserService_CreateGPGKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateGPGKeyOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.ArmoredKey != "-----BEGIN PGP PUBLIC KEY BLOCK-----" || body.Signature != "sig" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.GPGKey{ + ID: 9, + KeyID: "MNOP3456", + Verified: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.CreateGPGKey(context.Background(), &types.CreateGPGKeyOption{ + ArmoredKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", + Signature: "sig", + }) + if err != nil { + t.Fatal(err) + } + if key.ID != 9 || key.KeyID != "MNOP3456" || !key.Verified { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_GetGPGKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_keys/9" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GPGKey{ + ID: 9, + KeyID: "MNOP3456", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.GetGPGKey(context.Background(), 9) + if err != nil { + t.Fatal(err) + } + if key.ID != 9 || key.KeyID != "MNOP3456" { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_DeleteGPGKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_keys/9" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteGPGKey(context.Background(), 9); err != nil { + t.Fatal(err) + } +} + +func TestUserService_GetGPGKeyVerificationToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_key_token" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Write([]byte("verification-token")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + token, err := f.Users.GetGPGKeyVerificationToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "verification-token" { + t.Errorf("got token=%q, want %q", token, "verification-token") + } +} + +func TestUserService_VerifyGPGKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_key_verify" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "" { + t.Errorf("unexpected content type: %q", got) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.GPGKey{ + ID: 12, + KeyID: "QRST7890", + Verified: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.VerifyGPGKey(context.Background()) + if err != nil { + t.Fatal(err) + } + if key.ID != 12 || key.KeyID != "QRST7890" || !key.Verified { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_ListTokens_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.AccessToken{ + {ID: 1, Name: "ci", Scopes: []string{"repo"}}, + {ID: 2, Name: "deploy", Scopes: []string{"read:packages"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tokens, err := f.Users.ListTokens(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(tokens) != 2 || tokens[0].Name != "ci" || tokens[1].Name != "deploy" { + t.Fatalf("unexpected tokens: %+v", tokens) + } +} + +func TestUserService_CreateToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateAccessTokenOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "ci" || len(body.Scopes) != 1 || body.Scopes[0] != "repo" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.AccessToken{ + ID: 7, + Name: body.Name, + Scopes: body.Scopes, + Token: "abcdef0123456789", + TokenLastEight: "456789", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + token, err := f.Users.CreateToken(context.Background(), "alice", &types.CreateAccessTokenOption{ + Name: "ci", + Scopes: []string{"repo"}, + }) + if err != nil { + t.Fatal(err) + } + if token.ID != 7 || token.Name != "ci" || token.Token != "abcdef0123456789" { + t.Fatalf("unexpected token: %+v", token) + } +} + +func TestUserService_DeleteToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens/ci" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteToken(context.Background(), "alice", "ci"); err != nil { + t.Fatal(err) + } +} + +func TestUserService_ListUserKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("fingerprint"); got != "abc123" { + t.Errorf("wrong fingerprint: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PublicKey{ + {ID: 4, Title: "laptop", Fingerprint: "abc123"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListUserKeys(context.Background(), "alice", UserKeyListOptions{Fingerprint: "abc123"}) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Title != "laptop" { + t.Fatalf("unexpected keys: %+v", keys) + } +} + +func TestUserService_ListUserGPGKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/gpg_keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.GPGKey{ + {ID: 8, KeyID: "ABCD1234"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListUserGPGKeys(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].KeyID != "ABCD1234" { + t.Fatalf("unexpected gpg keys: %+v", keys) + } +} + +func TestUserService_ListOAuth2Applications_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/applications/oauth2" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.OAuth2Application{ + {ID: 1, Name: "CLI", ClientID: "cli", RedirectURIs: []string{"http://localhost:3000/callback"}}, + {ID: 2, Name: "Desktop", ClientID: "desktop"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + apps, err := f.Users.ListOAuth2Applications(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(apps) != 2 { + t.Fatalf("got %d applications, want 2", len(apps)) + } + if apps[0].ID != 1 || apps[0].Name != "CLI" { + t.Errorf("unexpected first application: %+v", apps[0]) + } +} + +func TestUserService_CreateOAuth2Application_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/applications/oauth2" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateOAuth2ApplicationOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "CLI" || !body.ConfidentialClient || len(body.RedirectURIs) != 1 || body.RedirectURIs[0] != "http://localhost:3000/callback" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.OAuth2Application{ + ID: 1, + Name: body.Name, + ClientID: "cli", + ClientSecret: "secret", + ConfidentialClient: body.ConfidentialClient, + RedirectURIs: body.RedirectURIs, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + app, err := f.Users.CreateOAuth2Application(context.Background(), &types.CreateOAuth2ApplicationOptions{ + Name: "CLI", + ConfidentialClient: true, + RedirectURIs: []string{"http://localhost:3000/callback"}, + }) + if err != nil { + t.Fatal(err) + } + if app.ID != 1 || app.ClientSecret != "secret" { + t.Errorf("unexpected application: %+v", app) + } +} + +func TestUserService_GetOAuth2Application_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/applications/oauth2/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.OAuth2Application{ + ID: 7, + Name: "CLI", + ClientID: "cli", + ConfidentialClient: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + app, err := f.Users.GetOAuth2Application(context.Background(), 7) + if err != nil { + t.Fatal(err) + } + if app.ID != 7 || app.ClientID != "cli" { + t.Errorf("unexpected application: %+v", app) + } +} + +func TestUserService_UpdateOAuth2Application_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/applications/oauth2/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateOAuth2ApplicationOptions + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "CLI v2" || len(body.RedirectURIs) != 2 { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.OAuth2Application{ + ID: 7, + Name: body.Name, + ClientID: "cli", + ClientSecret: "new-secret", + ConfidentialClient: body.ConfidentialClient, + RedirectURIs: body.RedirectURIs, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + app, err := f.Users.UpdateOAuth2Application(context.Background(), 7, &types.CreateOAuth2ApplicationOptions{ + Name: "CLI v2", + RedirectURIs: []string{"http://localhost:3000/callback", "http://localhost:3000/alt"}, + ConfidentialClient: false, + }) + if err != nil { + t.Fatal(err) + } + if app.Name != "CLI v2" || app.ClientSecret != "new-secret" { + t.Errorf("unexpected application: %+v", app) + } +} + +func TestUserService_DeleteOAuth2Application_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/applications/oauth2/7" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteOAuth2Application(context.Background(), 7); err != nil { + t.Fatal(err) + } +} + +func TestUserService_ListFollowers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -82,3 +1499,193 @@ func TestUserService_Good_ListFollowers(t *testing.T) { t.Errorf("got username=%q, want %q", followers[0].UserName, "bob") } } + +func TestUserService_ListSubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Users.ListSubscriptions(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 { + t.Fatalf("got %d repositories, want 1", len(repos)) + } + if repos[0].Name != "go-forge" { + t.Errorf("got name=%q, want %q", repos[0].Name, "go-forge") + } +} + +func TestUserService_IterSubscriptions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/subscriptions" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for repo, err := range f.Users.IterSubscriptions(context.Background(), "alice") { + if err != nil { + t.Fatal(err) + } + count++ + if repo.Name != "go-forge" { + t.Errorf("got name=%q, want %q", repo.Name, "go-forge") + } + } + if count != 1 { + t.Fatalf("got %d repositories, want 1", count) + } +} + +func TestUserService_ListMyStarred_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/starred" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + repos, err := f.Users.ListMyStarred(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(repos) != 1 { + t.Fatalf("got %d repositories, want 1", len(repos)) + } + if repos[0].FullName != "core/go-forge" { + t.Errorf("got full_name=%q, want %q", repos[0].FullName, "core/go-forge") + } +} + +func TestUserService_IterMyStarred_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/starred" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{ + {Name: "go-forge", FullName: "core/go-forge"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + count := 0 + for repo, err := range f.Users.IterMyStarred(context.Background()) { + if err != nil { + t.Fatal(err) + } + count++ + if repo.Name != "go-forge" { + t.Errorf("got name=%q, want %q", repo.Name, "go-forge") + } + } + if count != 1 { + t.Fatalf("got %d repositories, want 1", count) + } +} + +func TestUserService_CheckStarring_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/starred/core/go-forge" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + starring, err := f.Users.CheckStarring(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if !starring { + t.Fatal("got starring=false, want true") + } +} + +func TestUserService_CheckStarring_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/starred/core/go-forge" { + t.Errorf("wrong path: %s", r.URL.Path) + } + http.NotFound(w, r) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + starring, err := f.Users.CheckStarring(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if starring { + t.Fatal("got starring=true, want false") + } +} + +func TestUserService_GetHeatmap_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/heatmap" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.UserHeatmapData{ + {Contributions: 3}, + {Contributions: 7}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + heatmap, err := f.Users.GetHeatmap(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(heatmap) != 2 { + t.Fatalf("got %d heatmap points, want 2", len(heatmap)) + } + if heatmap[0].Contributions != 3 || heatmap[1].Contributions != 7 { + t.Errorf("unexpected heatmap data: %+v", heatmap) + } +} diff --git a/webhooks.go b/webhooks.go index b814b28..0824aba 100644 --- a/webhooks.go +++ b/webhooks.go @@ -2,7 +2,6 @@ package forge import ( "context" - "fmt" "iter" "dappco.re/go/core/forge/types" @@ -10,6 +9,11 @@ import ( // WebhookService handles webhook (hook) operations within a repository. // Embeds Resource for standard CRUD on /api/v1/repos/{owner}/{repo}/hooks/{id}. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Webhooks.ListAll(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type WebhookService struct { Resource[types.Hook, types.CreateHookOption, types.EditHookOption] } @@ -22,20 +26,160 @@ func newWebhookService(c *Client) *WebhookService { } } +// ListHooks returns all webhooks for a repository. +func (s *WebhookService) ListHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Hook](ctx, s.client, path, nil) +} + +// IterHooks returns an iterator over all webhooks for a repository. +func (s *WebhookService) IterHooks(ctx context.Context, owner, repo string) iter.Seq2[types.Hook, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) + return ListIter[types.Hook](ctx, s.client, path, nil) +} + +// CreateHook creates a webhook for a repository. +func (s *WebhookService) CreateHook(ctx context.Context, owner, repo string, opts *types.CreateHookOption) (*types.Hook, error) { + var out types.Hook + if err := s.client.Post(ctx, ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)), opts, &out); err != nil { + return nil, err + } + return &out, nil +} + // TestHook triggers a test delivery for a webhook. func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/tests", owner, repo, id) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/{id}/tests", pathParams("owner", owner, "repo", repo, "id", int64String(id))) return s.client.Post(ctx, path, nil, nil) } +// ListGitHooks returns all Git hooks for a repository. +func (s *WebhookService) ListGitHooks(ctx context.Context, owner, repo string) ([]types.GitHook, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/git", pathParams("owner", owner, "repo", repo)) + return ListAll[types.GitHook](ctx, s.client, path, nil) +} + +// IterGitHooks returns an iterator over all Git hooks for a repository. +func (s *WebhookService) IterGitHooks(ctx context.Context, owner, repo string) iter.Seq2[types.GitHook, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/git", pathParams("owner", owner, "repo", repo)) + return ListIter[types.GitHook](ctx, s.client, path, nil) +} + +// GetGitHook returns a single Git hook for a repository. +func (s *WebhookService) GetGitHook(ctx context.Context, owner, repo, id string) (*types.GitHook, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/git/{id}", pathParams("owner", owner, "repo", repo, "id", id)) + var out types.GitHook + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditGitHook updates an existing Git hook in a repository. +func (s *WebhookService) EditGitHook(ctx context.Context, owner, repo, id string, opts *types.EditGitHookOption) (*types.GitHook, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/git/{id}", pathParams("owner", owner, "repo", repo, "id", id)) + var out types.GitHook + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteGitHook deletes a Git hook from a repository. +func (s *WebhookService) DeleteGitHook(ctx context.Context, owner, repo, id string) error { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/git/{id}", pathParams("owner", owner, "repo", repo, "id", id)) + return s.client.Delete(ctx, path) +} + +// ListUserHooks returns all webhooks for the authenticated user. +func (s *WebhookService) ListUserHooks(ctx context.Context) ([]types.Hook, error) { + return ListAll[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) +} + +// IterUserHooks returns an iterator over all webhooks for the authenticated user. +func (s *WebhookService) IterUserHooks(ctx context.Context) iter.Seq2[types.Hook, error] { + return ListIter[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) +} + +// GetUserHook returns a single webhook for the authenticated user. +func (s *WebhookService) GetUserHook(ctx context.Context, id int64) (*types.Hook, error) { + path := ResolvePath("/api/v1/user/hooks/{id}", pathParams("id", int64String(id))) + var out types.Hook + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateUserHook creates a webhook for the authenticated user. +func (s *WebhookService) CreateUserHook(ctx context.Context, opts *types.CreateHookOption) (*types.Hook, error) { + var out types.Hook + if err := s.client.Post(ctx, "/api/v1/user/hooks", opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditUserHook updates an existing authenticated-user webhook. +func (s *WebhookService) EditUserHook(ctx context.Context, id int64, opts *types.EditHookOption) (*types.Hook, error) { + path := ResolvePath("/api/v1/user/hooks/{id}", pathParams("id", int64String(id))) + var out types.Hook + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteUserHook deletes an authenticated-user webhook. +func (s *WebhookService) DeleteUserHook(ctx context.Context, id int64) error { + path := ResolvePath("/api/v1/user/hooks/{id}", pathParams("id", int64String(id))) + return s.client.Delete(ctx, path) +} + // ListOrgHooks returns all webhooks for an organisation. func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types.Hook, error) { - path := fmt.Sprintf("/api/v1/orgs/%s/hooks", org) + path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) return ListAll[types.Hook](ctx, s.client, path, nil) } // IterOrgHooks returns an iterator over all webhooks for an organisation. func (s *WebhookService) IterOrgHooks(ctx context.Context, org string) iter.Seq2[types.Hook, error] { - path := fmt.Sprintf("/api/v1/orgs/%s/hooks", org) + path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) return ListIter[types.Hook](ctx, s.client, path, nil) } + +// GetOrgHook returns a single webhook for an organisation. +func (s *WebhookService) GetOrgHook(ctx context.Context, org string, id int64) (*types.Hook, error) { + path := ResolvePath("/api/v1/orgs/{org}/hooks/{id}", pathParams("org", org, "id", int64String(id))) + var out types.Hook + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgHook creates a webhook for an organisation. +func (s *WebhookService) CreateOrgHook(ctx context.Context, org string, opts *types.CreateHookOption) (*types.Hook, error) { + path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) + var out types.Hook + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditOrgHook updates an existing organisation webhook. +func (s *WebhookService) EditOrgHook(ctx context.Context, org string, id int64, opts *types.EditHookOption) (*types.Hook, error) { + path := ResolvePath("/api/v1/orgs/{org}/hooks/{id}", pathParams("org", org, "id", int64String(id))) + var out types.Hook + if err := s.client.Patch(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteOrgHook deletes an organisation webhook. +func (s *WebhookService) DeleteOrgHook(ctx context.Context, org string, id int64) error { + path := ResolvePath("/api/v1/orgs/{org}/hooks/{id}", pathParams("org", org, "id", int64String(id))) + return s.client.Delete(ctx, path) +} diff --git a/webhooks_extra_test.go b/webhooks_extra_test.go new file mode 100644 index 0000000..b250582 --- /dev/null +++ b/webhooks_extra_test.go @@ -0,0 +1,69 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestWebhookService_ListHooks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Hook{{ID: 1, Type: "forgejo"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hooks, err := f.Webhooks.ListHooks(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(hooks) != 1 || hooks[0].ID != 1 { + t.Fatalf("got %#v", hooks) + } +} + +func TestWebhookService_CreateHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateHookOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Type != "forgejo" { + t.Fatalf("unexpected body: %+v", body) + } + json.NewEncoder(w).Encode(types.Hook{ID: 1, Type: body.Type}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.CreateHook(context.Background(), "core", "go-forge", &types.CreateHookOption{ + Type: "forgejo", + Config: &types.CreateHookOptionConfig{ + "content_type": "json", + "url": "https://example.com/hook", + }, + }) + if err != nil { + t.Fatal(err) + } + if hook.Type != "forgejo" { + t.Fatalf("got type=%q", hook.Type) + } +} diff --git a/webhooks_test.go b/webhooks_test.go index 4098061..68d1ff9 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestWebhookService_Good_List(t *testing.T) { +func TestWebhookService_List_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -36,7 +36,7 @@ func TestWebhookService_Good_List(t *testing.T) { } } -func TestWebhookService_Good_Get(t *testing.T) { +func TestWebhookService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -70,7 +70,7 @@ func TestWebhookService_Good_Get(t *testing.T) { } } -func TestWebhookService_Good_Create(t *testing.T) { +func TestWebhookService_Create_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -108,7 +108,7 @@ func TestWebhookService_Good_Create(t *testing.T) { } } -func TestWebhookService_Good_TestHook(t *testing.T) { +func TestWebhookService_TestHook_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -127,7 +127,303 @@ func TestWebhookService_Good_TestHook(t *testing.T) { } } -func TestWebhookService_Good_ListOrgHooks(t *testing.T) { +func TestWebhookService_ListGitHooks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks/git" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.GitHook{ + {Name: "pre-receive", Content: "#!/bin/sh\nexit 0", IsActive: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hooks, err := f.Webhooks.ListGitHooks(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(hooks) != 1 { + t.Fatalf("got %d hooks, want 1", len(hooks)) + } + if hooks[0].Name != "pre-receive" { + t.Errorf("got name=%q, want %q", hooks[0].Name, "pre-receive") + } +} + +func TestWebhookService_IterGitHooks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks/git" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.GitHook{ + {Name: "pre-receive", Content: "#!/bin/sh\nexit 0", IsActive: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var got []string + for hook, err := range f.Webhooks.IterGitHooks(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + got = append(got, hook.Name) + } + if len(got) != 1 { + t.Fatalf("got %d hooks, want 1", len(got)) + } + if got[0] != "pre-receive" { + t.Errorf("got name=%q, want %q", got[0], "pre-receive") + } +} + +func TestWebhookService_GetGitHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks/git/pre-receive" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.GitHook{ + Name: "pre-receive", + Content: "#!/bin/sh\nexit 0", + IsActive: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.GetGitHook(context.Background(), "core", "go-forge", "pre-receive") + if err != nil { + t.Fatal(err) + } + if hook.Name != "pre-receive" { + t.Errorf("got name=%q, want %q", hook.Name, "pre-receive") + } + if !hook.IsActive { + t.Error("expected is_active=true") + } +} + +func TestWebhookService_EditGitHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks/git/pre-receive" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditGitHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Content != "#!/bin/sh\nexit 0" { + t.Fatalf("unexpected edit payload: %+v", opts) + } + json.NewEncoder(w).Encode(types.GitHook{ + Name: "pre-receive", + Content: opts.Content, + IsActive: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.EditGitHook(context.Background(), "core", "go-forge", "pre-receive", &types.EditGitHookOption{ + Content: "#!/bin/sh\nexit 0", + }) + if err != nil { + t.Fatal(err) + } + if hook.Content != "#!/bin/sh\nexit 0" { + t.Errorf("got content=%q, want %q", hook.Content, "#!/bin/sh\nexit 0") + } +} + +func TestWebhookService_DeleteGitHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks/git/pre-receive" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Webhooks.DeleteGitHook(context.Background(), "core", "go-forge", "pre-receive"); err != nil { + t.Fatal(err) + } +} + +func TestWebhookService_ListUserHooks_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Hook{ + {ID: 20, Type: "forgejo", Active: true}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hooks, err := f.Webhooks.ListUserHooks(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(hooks) != 1 { + t.Fatalf("got %d hooks, want 1", len(hooks)) + } + if hooks[0].ID != 20 { + t.Errorf("got id=%d, want 20", hooks[0].ID) + } +} + +func TestWebhookService_GetUserHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/hooks/20" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 20, + Type: "forgejo", + Active: true, + URL: "https://example.com/user-hook", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.GetUserHook(context.Background(), 20) + if err != nil { + t.Fatal(err) + } + if hook.ID != 20 { + t.Errorf("got id=%d, want 20", hook.ID) + } + if hook.URL != "https://example.com/user-hook" { + t.Errorf("got url=%q, want %q", hook.URL, "https://example.com/user-hook") + } +} + +func TestWebhookService_CreateUserHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Type != "forgejo" { + t.Errorf("got type=%q, want %q", opts.Type, "forgejo") + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 21, + Type: opts.Type, + Active: opts.Active, + Events: opts.Events, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.CreateUserHook(context.Background(), &types.CreateHookOption{ + Type: "forgejo", + Active: true, + Events: []string{"push"}, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 21 { + t.Errorf("got id=%d, want 21", hook.ID) + } + if hook.Type != "forgejo" { + t.Errorf("got type=%q, want %q", hook.Type, "forgejo") + } +} + +func TestWebhookService_EditUserHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/hooks/20" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Active != false { + t.Fatalf("unexpected edit payload: %+v", opts) + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 20, + Type: "forgejo", + Active: false, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.EditUserHook(context.Background(), 20, &types.EditHookOption{ + Active: false, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 20 { + t.Errorf("got id=%d, want 20", hook.ID) + } + if hook.Active { + t.Error("expected active=false") + } +} + +func TestWebhookService_DeleteUserHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/hooks/20" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Webhooks.DeleteUserHook(context.Background(), 20); err != nil { + t.Fatal(err) + } +} + +func TestWebhookService_ListOrgHooks_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -155,7 +451,135 @@ func TestWebhookService_Good_ListOrgHooks(t *testing.T) { } } -func TestWebhookService_Bad_NotFound(t *testing.T) { +func TestWebhookService_GetOrgHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/myorg/hooks/10" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 10, + Type: "forgejo", + Active: true, + URL: "https://example.com/org-hook", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.GetOrgHook(context.Background(), "myorg", 10) + if err != nil { + t.Fatal(err) + } + if hook.ID != 10 { + t.Errorf("got id=%d, want 10", hook.ID) + } + if hook.URL != "https://example.com/org-hook" { + t.Errorf("got url=%q, want %q", hook.URL, "https://example.com/org-hook") + } +} + +func TestWebhookService_CreateOrgHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/myorg/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Type != "forgejo" { + t.Errorf("got type=%q, want %q", opts.Type, "forgejo") + } + json.NewEncoder(w).Encode(types.Hook{ + ID: 11, + Type: opts.Type, + Active: opts.Active, + Events: opts.Events, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.CreateOrgHook(context.Background(), "myorg", &types.CreateHookOption{ + Type: "forgejo", + Active: true, + Events: []string{"push"}, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 11 { + t.Errorf("got id=%d, want 11", hook.ID) + } + if hook.Type != "forgejo" { + t.Errorf("got type=%q, want %q", hook.Type, "forgejo") + } +} + +func TestWebhookService_EditOrgHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/myorg/hooks/10" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.EditHookOption + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.Active != false { + t.Fatalf("unexpected edit payload: %+v", opts) + } + active := false + json.NewEncoder(w).Encode(types.Hook{ + ID: 10, + Type: "forgejo", + Active: active, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + hook, err := f.Webhooks.EditOrgHook(context.Background(), "myorg", 10, &types.EditHookOption{ + Active: false, + }) + if err != nil { + t.Fatal(err) + } + if hook.ID != 10 { + t.Errorf("got id=%d, want 10", hook.ID) + } + if hook.Active { + t.Error("expected active=false") + } +} + +func TestWebhookService_DeleteOrgHook_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/myorg/hooks/10" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Webhooks.DeleteOrgHook(context.Background(), "myorg", 10); err != nil { + t.Fatal(err) + } +} + +func TestWebhookService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "hook not found"}) diff --git a/wiki.go b/wiki.go index 898c5ca..2243b3d 100644 --- a/wiki.go +++ b/wiki.go @@ -2,13 +2,19 @@ package forge import ( "context" - "fmt" + "iter" + "strconv" "dappco.re/go/core/forge/types" ) // WikiService handles wiki page operations for a repository. // No Resource embedding — custom endpoints for wiki CRUD. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _, err := f.Wiki.ListPages(ctx, "core", "go-forge") type WikiService struct { client *Client } @@ -19,7 +25,7 @@ func newWikiService(c *Client) *WikiService { // ListPages returns all wiki page metadata for a repository. func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]types.WikiPageMetaData, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/pages", pathParams("owner", owner, "repo", repo)) var out []types.WikiPageMetaData if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -27,9 +33,25 @@ func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]type return out, nil } +// IterPages returns an iterator over all wiki page metadata for a repository. +func (s *WikiService) IterPages(ctx context.Context, owner, repo string) iter.Seq2[types.WikiPageMetaData, error] { + return func(yield func(types.WikiPageMetaData, error) bool) { + items, err := s.ListPages(ctx, owner, repo) + if err != nil { + yield(*new(types.WikiPageMetaData), err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + } +} + // GetPage returns a single wiki page by name. func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/page/{pageName}", pathParams("owner", owner, "repo", repo, "pageName", pageName)) var out types.WikiPage if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -37,9 +59,23 @@ func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) return &out, nil } +// GetPageRevisions returns the revision history for a wiki page. +// Page is optional; pass a value greater than zero to request a specific page of results. +func (s *WikiService) GetPageRevisions(ctx context.Context, owner, repo, pageName string, page int) (*types.WikiCommitList, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/revisions/{pageName}", pathParams("owner", owner, "repo", repo, "pageName", pageName)) + if page > 0 { + path += "?page=" + strconv.Itoa(page) + } + var out types.WikiCommitList + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + // CreatePage creates a new wiki page. func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/new", pathParams("owner", owner, "repo", repo)) var out types.WikiPage if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -49,7 +85,7 @@ func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts * // EditPage updates an existing wiki page. func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/page/{pageName}", pathParams("owner", owner, "repo", repo, "pageName", pageName)) var out types.WikiPage if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -59,6 +95,6 @@ func (s *WikiService) EditPage(ctx context.Context, owner, repo, pageName string // DeletePage removes a wiki page. func (s *WikiService) DeletePage(ctx context.Context, owner, repo, pageName string) error { - path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/page/{pageName}", pathParams("owner", owner, "repo", repo, "pageName", pageName)) return s.client.Delete(ctx, path) } diff --git a/wiki_test.go b/wiki_test.go index 7889f78..ca2ab13 100644 --- a/wiki_test.go +++ b/wiki_test.go @@ -2,7 +2,7 @@ package forge import ( "context" - "encoding/json" + json "github.com/goccy/go-json" "net/http" "net/http/httptest" "testing" @@ -10,7 +10,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestWikiService_Good_ListPages(t *testing.T) { +func TestWikiService_ListPages_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -41,7 +41,38 @@ func TestWikiService_Good_ListPages(t *testing.T) { } } -func TestWikiService_Good_GetPage(t *testing.T) { +func TestWikiService_IterPages_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/wiki/pages" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode([]types.WikiPageMetaData{ + {Title: "Home", SubURL: "Home"}, + {Title: "Setup", SubURL: "Setup"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + var titles []string + for page, err := range f.Wiki.IterPages(context.Background(), "core", "go-forge") { + if err != nil { + t.Fatal(err) + } + titles = append(titles, page.Title) + } + if len(titles) != 2 { + t.Fatalf("got %d pages, want 2", len(titles)) + } + if titles[0] != "Home" || titles[1] != "Setup" { + t.Fatalf("unexpected titles: %+v", titles) + } +} + +func TestWikiService_GetPage_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -74,7 +105,44 @@ func TestWikiService_Good_GetPage(t *testing.T) { } } -func TestWikiService_Good_CreatePage(t *testing.T) { +func TestWikiService_GetPageRevisions_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/wiki/revisions/Home" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + json.NewEncoder(w).Encode(types.WikiCommitList{ + Count: 2, + WikiCommits: []*types.WikiCommit{ + {ID: "abc123", Message: "Initial import"}, + {ID: "def456", Message: "Updated home page"}, + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + revisions, err := f.Wiki.GetPageRevisions(context.Background(), "core", "go-forge", "Home", 2) + if err != nil { + t.Fatal(err) + } + if revisions.Count != 2 { + t.Fatalf("got count=%d, want 2", revisions.Count) + } + if len(revisions.WikiCommits) != 2 { + t.Fatalf("got %d revisions, want 2", len(revisions.WikiCommits)) + } + if revisions.WikiCommits[0].ID != "abc123" || revisions.WikiCommits[1].Message != "Updated home page" { + t.Fatalf("got %#v", revisions.WikiCommits) + } +} + +func TestWikiService_CreatePage_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -118,7 +186,7 @@ func TestWikiService_Good_CreatePage(t *testing.T) { } } -func TestWikiService_Good_EditPage(t *testing.T) { +func TestWikiService_EditPage_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) @@ -151,7 +219,7 @@ func TestWikiService_Good_EditPage(t *testing.T) { } } -func TestWikiService_Good_DeletePage(t *testing.T) { +func TestWikiService_DeletePage_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -170,7 +238,7 @@ func TestWikiService_Good_DeletePage(t *testing.T) { } } -func TestWikiService_Bad_NotFound(t *testing.T) { +func TestWikiService_NotFound_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "page not found"})